<timestamp>Saturday, Jul 18, 2026, 12:21 PM (UTC+2)</timestamp>
<user_query>
Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You might use sub-agents or team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you.

Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message.

When implementation is fully done, then make a full review meeting and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting.

Don't re-use any existing implementation in another branch. Build it from scratch.

</user_query>


I'll implement the full shop from the specs in one continuous pass: read the roadmap and current codebase, then build, test, and commit iteratively. Starting with the specs and project state.

Shell

ls -la && git branch -a && git status && ls app/ 2>/dev/null; ls database/ 2>/dev/null; head -n 50 composer.json

Inspect project state and branches


There's already some app code on this branch — I'll inspect whether it's leftover shop work or a clean starter, then follow the roadmap from scratch.

find app database/migrations database/seeders routes resources/views tests -type f 2>/dev/null | head -200; echo '---'; ls app/Models app/Services app/Livewire 2>/dev/null; echo '---'; ls database/migrations | wc -l; ls tests/Feature 2>/dev/null | head -50

Inventory existing app structure

CallMcpTool

Get Laravel app package info

Get database schema summary


Shell

find app/Livewire/Admin app/Livewire/Storefront app/Services app/Enums app/Http -type f 2>/dev/null; ls -la database/; ls draft.yaml 2>/dev/null; composer show laravel-shift/blueprint 2>/dev/null | head -5; cat .env | grep -E 'DB_|CACHE_|SESSION_|QUEUE_|MAIL_|FILESYSTEM'

Check partial code and Blueprint setup


Clean starter confirmed. I'll build the shop from the specs in phases, using parallel agents, and track commits in `specs/progress.md`.

ls -la app/Livewire/Admin app/Livewire/Storefront app/Services/Catalog app/Enums 2>/dev/null; ls database/schema database/factories; which php; php artisan list --raw 2>/dev/null | rg -i 'blueprint|make:model|make:livewire' | head -30; touch database/database.sqlite; php artisan migrate:status 2>&1 | head -20

Check empty dirs and artisan tools


I'll scaffold foundation first, then fan out phases with agents. Creating progress tracking and reading the remaining schema/config.

Write

# Shop Implementation Progress

Branch: `2026-07-18-cursor-grok-4-5`
Started: 2026-07-18
Approach: Build from scratch on clean Laravel Livewire starter (no reuse of other branches).

## Status Overview

| Phase | Name | Status | Notes |
|-------|------|--------|-------|
| 1 | Foundation | 🔄 in progress | Migrations, models, middleware, auth, policies |
| 2 | Catalog | ⏳ pending | Products, variants, inventory, collections, media |
| 3 | Themes & Storefront Layout | ⏳ pending | Themes, pages, nav, Blade layout |
| 4 | Cart, Checkout, Discounts, Shipping, Taxes | ⏳ pending | Core shopping flow |
| 5 | Payments, Orders, Fulfillment | ⏳ pending | Mock PSP, orders |
| 6 | Customer Accounts | ⏳ pending | Customer guard + account pages |
| 7 | Admin Panel | ⏳ pending | Livewire admin UI |
| 8 | Search | ⏳ pending | FTS5 + UI |
| 9 | Analytics | ⏳ pending | Events + daily aggregates |
| 10 | Apps and Webhooks | ⏳ pending | Extensibility |
| 11 | Polish | ⏳ pending | A11y, dark mode, seeders |
| 12 | Full Test Suite + Playwright | ⏳ pending | Pest + MCP confirmation |

## Iteration Log

### 2026-07-18 — Kickoff
- Confirmed clean starter (no shop domain code, empty DB).
- Specs loaded; roadmap phases 1–12 identified.
- Progress file created; Phase 1 starting.

CallMcpTool

middleware groups
custom auth guard
eloquent casts method
enum casting


Launching parallel agents for foundation schema/models and core services while I wire config and middleware.

Task

Phase 1-2 schema models

Prompt:
You are implementing a Laravel 12 e-commerce shop FROM SCRATCH on branch at /Users/fabianwesner/Herd/shop.

CRITICAL RULES:
- Build from scratch; do not copy code from other git branches.
- Follow specs in specs/01-DATABASE-SCHEMA.md and specs/09-IMPLEMENTATION-ROADMAP.md Phases 1-2.
- Use Pest for tests. Run pint --dirty --format agent on PHP changes.
- Pass --no-interaction to artisan.
- Keep changes focused on Phase 1-2 foundation + catalog data layer.
- Money as integer cents. Enums as backed string enums with TitleCase cases and lowercase values.
- Models use casts() method. Explicit return types.
- Schema managed via migrations (NOT Blueprint/draft.yaml - Blueprint is not installed).
- Do NOT commit unless you finish a coherent slice - parent will commit.
- Update specs/progress.md with what you completed under Phase 1/2.

YOUR TASK - Implement Phase 1 (foundation) and Phase 2 data layer (migrations, models, factories, enums, scopes, middleware):

1. Modify users migration to add: status (default active), last_login_at, keep two_factor from existing migration. Spec wants password_hash column - use column name `password` for Laravel/Fortify compatibility BUT document that it stores the hash (do NOT break Fortify). Add status CHECK via string column.

2. Create migrations for: organizations, stores, store_domains, store_users (composite PK), store_settings, products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media.

3. Create enums in app/Enums/: StoreStatus, StoreUserRole, StoreDomainType, UserStatus, ProductStatus, VariantStatus, CollectionStatus, MediaType, MediaStatus, InventoryPolicy.

4. Create models with relationships, factories, BelongsToStore trait + StoreScope.

5. Create ResolveStore middleware; register storefront and admin middleware groups in bootstrap/app.php.

6. Configure auth.php with customer guard/provider/password broker (Customer model can be stubbed for Phase 5 if needed - create Customer model+migration now if carts need FK later; for Phase 1-2 Customer can wait - but create customers table migration early if needed for FK). Actually create customers + customer_addresses migrations too so cart FK works later.

7. Update User model: stores() relationship, roleForStore(), fillable/casts for status.

8. Create policies stubs for Product, Order, Collection, Discount, Customer, Store, Page, Theme, Fulfillment, Refund with role matrix from roadmap.

9. Write Pest feature tests: tests/Feature/Tenancy/TenantResolutionTest.php, StoreIsolationTest.php, and basic model/factory tests.

10. Create seeders skeleton for Organization, Store, StoreDomain, User, StoreUser, StoreSettings.

11. Configure SQLite optimizations in config/database.php if needed (foreign_keys, busy_timeout).

12. Rate limiter for login in AppServiceProvider.

After done: run migrations, run the new tests, pint. Report files created and test results.

Read full relevant sections of specs/01-DATABASE-SCHEMA.md and specs/09-IMPLEMENTATION-ROADMAP.md Phase 1-2 before coding.


Waiting on the foundation agent — reading remaining specs and preparing Phase 3–5 work so we can continue immediately when it finishes.

Shell

ls -la /Users/fabianwesner/.cursor/projects/Users-fabianwesner-Herd-shop/agent-transcripts/ 2>/dev/null | tail -5; find /Users/fabianwesner/Herd/shop/app /Users/fabianwesner/Herd/shop/database/migrations -type f -newer /Users/fabianwesner/Herd/shop/specs/progress.md 2>/dev/null | head -40; ls /Users/fabianwesner/Herd/shop/database/migrations/

Check agent progress via new files


Implementing Phase 1 myself in parallel so we don't block on the background agent — generating migrations, enums, and models now.

# Create sqlite and generate migration stubs via artisan
cd /Users/fabianwesner/Herd/shop
touch database/database.sqlite

# Generate models with migrations/factories in batch
php artisan make:model Organization -mf --no-interaction
php artisan make:model Store -mf --no-interaction
php artisan make:model StoreDomain -mf --no-interaction
php artisan make:model StoreUser -m --no-interaction
php artisan make:model StoreSettings -mf --no-interaction
php artisan make:model Product -mf --no-interaction
php artisan make:model ProductOption -mf --no-interaction
php artisan make:model ProductOptionValue -mf --no-interaction
php artisan make:model ProductVariant -mf --no-interaction
php artisan make:model InventoryItem -mf --no-interaction
php artisan make:model Collection -mf --no-interaction
php artisan make:model ProductMedia -mf --no-interaction
php artisan make:model Customer -mf --no-interaction
php artisan make:model CustomerAddress -mf --no-interaction
php artisan make:class Models/Concerns/BelongsToStore --no-interaction
php artisan make:class Models/Scopes/StoreScope --no-interaction
php artisan make:class Http/Middleware/ResolveStore --no-interaction

# Enums
for e in StoreStatus StoreUserRole StoreDomainType UserStatus ProductStatus VariantStatus CollectionStatus MediaType MediaStatus InventoryPolicy CollectionType; do
  php artisan make:enum "Enums/$e" --string --no-interaction 2>/dev/null || php artisan make:enum "$e" --string --no-interaction
done

ls database/migrations/ | tail -30
ls app/Models/
ls app/Enums/ 2>/dev/null || ls app/Enums/Enums 2>/dev/null

Generate Phase 1-2 model scaffolding


Fixing enum paths and filling in migrations, models, and middleware.

cd /Users/fabianwesner/Herd/shop
# Fix nested Enums directory
mv app/Enums/Enums/*.php app/Enums/ 2>/dev/null
rmdir app/Enums/Enums 2>/dev/null
# Also remove empty Enums/Enums if nested weirdly
find app/Enums -type d -empty -delete 2>/dev/null
ls app/Enums/

# Extra pivot migrations
php artisan make:migration create_variant_option_values_table --no-interaction
php artisan make:migration create_collection_products_table --no-interaction
php artisan make:migration add_shop_columns_to_users_table --no-interaction

# Fix enum namespaces
for f in app/Enums/*.php; do
  sed -i '' 's/namespace App\\Enums\\Enums;/namespace App\\Enums;/' "$f"
done
head -5 app/Enums/StoreStatus.php

Fix enum paths and add pivot migrations


cd /Users/fabianwesner/Herd/shop

# Write all enums
python3 << 'PY'
from pathlib import Path

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")],
    "ProductStatus": [("Draft", "draft"), ("Active", "active"), ("Archived", "archived")],
    "VariantStatus": [("Active", "active"), ("Archived", "archived")],
    "CollectionStatus": [("Draft", "draft"), ("Active", "active"), ("Archived", "archived")],
    "CollectionType": [("Manual", "manual"), ("Automated", "automated")],
    "MediaType": [("Image", "image"), ("Video", "video")],
    "MediaStatus": [("Processing", "processing"), ("Ready", "ready"), ("Failed", "failed")],
    "InventoryPolicy": [("Deny", "deny"), ("Continue", "continue")],
}

for name, cases in enums.items():
    body = "\n".join(f"    case {c} = '{v}';" for c, v in cases)
    Path(f"app/Enums/{name}.php").write_text(f"""<?php

namespace App\\Enums;

enum {name}: string
{{
{body}
}}
""")
print("enums ok")
PY

# Write migrations via python for reliability
python3 << 'PY'
from pathlib import Path
import glob

def write_migration(pattern, content):
    files = sorted(glob.glob(f"database/migrations/*{pattern}*"))
    if not files:
        raise SystemExit(f"missing migration {pattern}")
    Path(files[-1]).write_text(content)
    print("wrote", files[-1])

write_migration("create_organizations_table", '''<?php

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

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

            $table->index(\'billing_email\', \'idx_organizations_billing_email\');
        });
    }

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

write_migration("create_stores_table", '''<?php

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

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

            $table->unique(\'handle\', \'idx_stores_handle\');
            $table->index(\'organization_id\', \'idx_stores_organization_id\');
            $table->index(\'status\', \'idx_stores_status\');
        });
    }

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

write_migration("create_store_domains_table", '''<?php

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

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

            $table->unique(\'hostname\', \'idx_store_domains_hostname\');
            $table->index(\'store_id\', \'idx_store_domains_store_id\');
            $table->index([\'store_id\', \'is_primary\'], \'idx_store_domains_store_primary\');
        });
    }

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

write_migration("create_store_users_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'store_users\', function (Blueprint $table) {
            $table->foreignId(\'store_id\')->constrained()->cascadeOnDelete();
            $table->foreignId(\'user_id\')->constrained()->cascadeOnDelete();
            $table->string(\'role\')->default(\'staff\');
            $table->timestamp(\'created_at\')->nullable();

            $table->primary([\'store_id\', \'user_id\']);
            $table->index(\'user_id\', \'idx_store_users_user_id\');
            $table->index([\'store_id\', \'role\'], \'idx_store_users_role\');
        });
    }

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

write_migration("create_store_settings_table", '''<?php

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

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

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

write_migration("add_shop_columns_to_users_table", '''<?php

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

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

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

write_migration("create_products_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'products\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'store_id\')->constrained()->cascadeOnDelete();
            $table->string(\'title\');
            $table->string(\'handle\');
            $table->string(\'status\')->default(\'draft\');
            $table->text(\'description_html\')->nullable();
            $table->string(\'vendor\')->nullable();
            $table->string(\'product_type\')->nullable();
            $table->text(\'tags\')->default(\'[]\');
            $table->timestamp(\'published_at\')->nullable();
            $table->timestamps();

            $table->unique([\'store_id\', \'handle\'], \'idx_products_store_handle\');
            $table->index(\'store_id\', \'idx_products_store_id\');
            $table->index([\'store_id\', \'status\'], \'idx_products_store_status\');
            $table->index([\'store_id\', \'published_at\'], \'idx_products_published_at\');
            $table->index([\'store_id\', \'vendor\'], \'idx_products_vendor\');
            $table->index([\'store_id\', \'product_type\'], \'idx_products_product_type\');
        });
    }

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

write_migration("create_product_options_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'product_options\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'product_id\')->constrained()->cascadeOnDelete();
            $table->string(\'name\');
            $table->integer(\'position\')->default(0);

            $table->index(\'product_id\', \'idx_product_options_product_id\');
            $table->unique([\'product_id\', \'position\'], \'idx_product_options_product_position\');
        });
    }

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

write_migration("create_product_option_values_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'product_option_values\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'product_option_id\')->constrained()->cascadeOnDelete();
            $table->string(\'value\');
            $table->integer(\'position\')->default(0);

            $table->index(\'product_option_id\', \'idx_product_option_values_option_id\');
            $table->unique([\'product_option_id\', \'position\'], \'idx_product_option_values_option_position\');
        });
    }

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

write_migration("create_product_variants_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'product_variants\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'product_id\')->constrained()->cascadeOnDelete();
            $table->string(\'sku\')->nullable();
            $table->string(\'barcode\')->nullable();
            $table->integer(\'price_amount\')->default(0);
            $table->integer(\'compare_at_amount\')->nullable();
            $table->string(\'currency\')->default(\'USD\');
            $table->integer(\'weight_g\')->nullable();
            $table->boolean(\'requires_shipping\')->default(true);
            $table->boolean(\'is_default\')->default(false);
            $table->integer(\'position\')->default(0);
            $table->string(\'status\')->default(\'active\');
            $table->timestamps();

            $table->index(\'product_id\', \'idx_product_variants_product_id\');
            $table->index(\'sku\', \'idx_product_variants_sku\');
            $table->index(\'barcode\', \'idx_product_variants_barcode\');
            $table->index([\'product_id\', \'position\'], \'idx_product_variants_product_position\');
            $table->index([\'product_id\', \'is_default\'], \'idx_product_variants_product_default\');
        });
    }

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

write_migration("create_variant_option_values_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'variant_option_values\', function (Blueprint $table) {
            $table->foreignId(\'variant_id\')->constrained(\'product_variants\')->cascadeOnDelete();
            $table->foreignId(\'product_option_value_id\')->constrained()->cascadeOnDelete();

            $table->primary([\'variant_id\', \'product_option_value_id\']);
            $table->index(\'product_option_value_id\', \'idx_variant_option_values_value_id\');
        });
    }

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

write_migration("create_inventory_items_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'inventory_items\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'store_id\')->constrained()->cascadeOnDelete();
            $table->foreignId(\'variant_id\')->constrained(\'product_variants\')->cascadeOnDelete();
            $table->integer(\'quantity_on_hand\')->default(0);
            $table->integer(\'quantity_reserved\')->default(0);
            $table->string(\'policy\')->default(\'deny\');

            $table->unique(\'variant_id\', \'idx_inventory_items_variant_id\');
            $table->index(\'store_id\', \'idx_inventory_items_store_id\');
        });
    }

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

write_migration("create_collections_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'collections\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'store_id\')->constrained()->cascadeOnDelete();
            $table->string(\'title\');
            $table->string(\'handle\');
            $table->text(\'description_html\')->nullable();
            $table->string(\'type\')->default(\'manual\');
            $table->string(\'status\')->default(\'active\');
            $table->timestamps();

            $table->unique([\'store_id\', \'handle\'], \'idx_collections_store_handle\');
            $table->index(\'store_id\', \'idx_collections_store_id\');
            $table->index([\'store_id\', \'status\'], \'idx_collections_store_status\');
        });
    }

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

write_migration("create_collection_products_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'collection_products\', function (Blueprint $table) {
            $table->foreignId(\'collection_id\')->constrained()->cascadeOnDelete();
            $table->foreignId(\'product_id\')->constrained()->cascadeOnDelete();
            $table->integer(\'position\')->default(0);

            $table->primary([\'collection_id\', \'product_id\']);
            $table->index(\'product_id\', \'idx_collection_products_product_id\');
            $table->index([\'collection_id\', \'position\'], \'idx_collection_products_position\');
        });
    }

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

write_migration("create_product_media_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'product_media\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'product_id\')->constrained()->cascadeOnDelete();
            $table->string(\'type\')->default(\'image\');
            $table->string(\'storage_key\');
            $table->string(\'alt_text\')->nullable();
            $table->integer(\'width\')->nullable();
            $table->integer(\'height\')->nullable();
            $table->string(\'mime_type\')->nullable();
            $table->integer(\'byte_size\')->nullable();
            $table->integer(\'position\')->default(0);
            $table->string(\'status\')->default(\'processing\');
            $table->timestamp(\'created_at\')->nullable();

            $table->index(\'product_id\', \'idx_product_media_product_id\');
            $table->index([\'product_id\', \'position\'], \'idx_product_media_product_position\');
            $table->index(\'status\', \'idx_product_media_status\');
        });
    }

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

write_migration("create_customers_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'customers\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'store_id\')->constrained()->cascadeOnDelete();
            $table->string(\'email\');
            $table->string(\'password\')->nullable();
            $table->string(\'name\')->nullable();
            $table->boolean(\'marketing_opt_in\')->default(false);
            $table->rememberToken();
            $table->timestamps();

            $table->unique([\'store_id\', \'email\'], \'idx_customers_store_email\');
            $table->index(\'store_id\', \'idx_customers_store_id\');
        });
    }

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

write_migration("create_customer_addresses_table", '''<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(\'customer_addresses\', function (Blueprint $table) {
            $table->id();
            $table->foreignId(\'customer_id\')->constrained()->cascadeOnDelete();
            $table->string(\'label\')->nullable();
            $table->text(\'address_json\')->default(\'{}\');
            $table->boolean(\'is_default\')->default(false);

            $table->index(\'customer_id\', \'idx_customer_addresses_customer_id\');
            $table->index([\'customer_id\', \'is_default\'], \'idx_customer_addresses_default\');
        });
    }

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

print("all migrations written")
PY

Write enums and all Phase 1-2 migrations


Fixing migration order so `stores` runs before `store_domains`, then writing models.

cd /Users/fabianwesner/Herd/shop
mv database/migrations/2026_07_18_102311_create_store_domains_table.php database/migrations/2026_07_18_102311_5_create_store_domains_table.php
# Actually Laravel sorts alphabetically - organizations, store_domains, stores is wrong
# Better rename with clear sequence
mv database/migrations/2026_07_18_102311_create_organizations_table.php database/migrations/2026_07_18_102300_create_organizations_table.php
mv database/migrations/2026_07_18_102311_create_stores_table.php database/migrations/2026_07_18_102301_create_stores_table.php
mv database/migrations/2026_07_18_102311_5_create_store_domains_table.php database/migrations/2026_07_18_102302_create_store_domains_table.php
mv database/migrations/2026_07_18_102319_add_shop_columns_to_users_table.php database/migrations/2026_07_18_102303_add_shop_columns_to_users_table.php
mv database/migrations/2026_07_18_102312_create_store_users_table.php database/migrations/2026_07_18_102304_create_store_users_table.php
mv database/migrations/2026_07_18_102312_create_store_settings_table.php database/migrations/2026_07_18_102305_create_store_settings_table.php
mv database/migrations/2026_07_18_102313_create_customers_table.php database/migrations/2026_07_18_102306_create_customers_table.php
mv database/migrations/2026_07_18_102313_create_customer_addresses_table.php database/migrations/2026_07_18_102307_create_customer_addresses_table.php
mv database/migrations/2026_07_18_102312_create_products_table.php database/migrations/2026_07_18_102308_create_products_table.php
mv database/migrations/2026_07_18_102312_create_product_options_table.php database/migrations/2026_07_18_102309_create_product_options_table.php
mv database/migrations/2026_07_18_102312_create_product_option_values_table.php database/migrations/2026_07_18_102310_create_product_option_values_table.php
mv database/migrations/2026_07_18_102312_create_product_variants_table.php database/migrations/2026_07_18_102311_create_product_variants_table.php
mv database/migrations/2026_07_18_102319_create_variant_option_values_table.php database/migrations/2026_07_18_102312_create_variant_option_values_table.php
mv database/migrations/2026_07_18_102313_create_inventory_items_table.php database/migrations/2026_07_18_102313_create_inventory_items_table.php
mv database/migrations/2026_07_18_102313_create_collections_table.php database/migrations/2026_07_18_102314_create_collections_table.php
mv database/migrations/2026_07_18_102319_create_collection_products_table.php database/migrations/2026_07_18_102315_create_collection_products_table.php
mv database/migrations/2026_07_18_102313_create_product_media_table.php database/migrations/2026_07_18_102316_create_product_media_table.php
ls database/migrations/

Reorder migrations by dependency

Write

<?php

namespace App\Models\Concerns;

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

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

        static::creating(function (Model $model): void {
            if ($model->getAttribute('store_id') !== null) {
                return;
            }

            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if ($store instanceof Store) {
                $model->setAttribute('store_id', $store->id);
            }
        });
    }

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

<?php

namespace App\Models\Scopes;

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

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

        $store = app('current_store');

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

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

<?php

namespace App\Http\Middleware;

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

class ResolveStore
{
    public function handle(Request $request, Closure $next): Response
    {
        if ($request->is('admin', 'admin/*')) {
            return $this->resolveAdminStore($request, $next);
        }

        return $this->resolveStorefrontStore($request, $next);
    }

    private function resolveStorefrontStore(Request $request, Closure $next): Response
    {
        $hostname = $request->getHost();
        $cacheKey = 'store_domain:'.$hostname;

        $storeId = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($hostname): ?int {
            return StoreDomain::query()
                ->where('hostname', $hostname)
                ->value('store_id');
        });

        if ($storeId === null) {
            abort(404);
        }

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

        if ($store === null) {
            abort(404);
        }

        if ($store->status === StoreStatus::Suspended) {
            abort(503);
        }

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

        return $next($request);
    }

    private function resolveAdminStore(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if ($user === null) {
            return $next($request);
        }

        $storeId = $request->session()->get('current_store_id');

        if ($storeId === null) {
            $storeId = $user->stores()->value('stores.id');

            if ($storeId !== null) {
                $request->session()->put('current_store_id', $storeId);
            }
        }

        if ($storeId === null) {
            abort(403);
        }

        $store = $user->stores()->where('stores.id', $storeId)->first();

        if ($store === null) {
            abort(403);
        }

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

        return $next($request);
    }
}


Shell

cd /Users/fabianwesner/Herd/shop
python3 << 'PY'
from pathlib import Path

models = {}

models["Organization.php"] = r'''<?php

namespace App\Models;

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

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

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

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

models["Store.php"] = r'''<?php

namespace App\Models;

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

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

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

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

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

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

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

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

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

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

    public function customers(): HasMany
    {
        return $this->hasMany(Customer::class);
    }
}
'''

models["StoreDomain.php"] = r'''<?php

namespace App\Models;

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

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

    public $timestamps = false;

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

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

    protected static function booted(): void
    {
        static::creating(function (StoreDomain $domain): void {
            if ($domain->created_at === null) {
                $domain->created_at = now();
            }
        });
    }

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

models["StoreUser.php"] = r'''<?php

namespace App\Models;

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

class StoreUser extends Pivot
{
    public $incrementing = false;

    public $timestamps = false;

    protected $table = 'store_users';

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

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

    protected static function booted(): void
    {
        static::creating(function (StoreUser $pivot): void {
            if ($pivot->created_at === null) {
                $pivot->created_at = now();
            }
        });
    }

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

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
'''

models["StoreSettings.php"] = r'''<?php

namespace App\Models;

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

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

    public $incrementing = false;

    public $timestamps = false;

    protected $primaryKey = 'store_id';

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

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

    protected static function booted(): void
    {
        static::saving(function (StoreSettings $settings): void {
            $settings->updated_at = now();
        });
    }

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

models["Product.php"] = r'''<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

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

    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'status',
        'description_html',
        'vendor',
        'product_type',
        'tags',
        'published_at',
    ];

    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'tags' => 'array',
            'published_at' => 'datetime',
        ];
    }

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

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

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

    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')
            ->withPivot('position');
    }
}
'''

models["ProductOption.php"] = r'''<?php

namespace App\Models;

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

class ProductOption extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'product_id',
        'name',
        'position',
    ];

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

    public function values(): HasMany
    {
        return $this->hasMany(ProductOptionValue::class);
    }
}
'''

models["ProductOptionValue.php"] = r'''<?php

namespace App\Models;

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

class ProductOptionValue extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionValueFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'product_option_id',
        'value',
        'position',
    ];

    public function option(): BelongsTo
    {
        return $this->belongsTo(ProductOption::class, 'product_option_id');
    }

    public function variants(): BelongsToMany
    {
        return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id');
    }
}
'''

models["ProductVariant.php"] = r'''<?php

namespace App\Models;

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

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    protected function casts(): array
    {
        return [
            'status' => VariantStatus::class,
            'requires_shipping' => 'boolean',
            'is_default' => 'boolean',
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
        ];
    }

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

    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }
}
'''

models["InventoryItem.php"] = r'''<?php

namespace App\Models;

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

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

    public $timestamps = false;

    protected $fillable = [
        'store_id',
        'variant_id',
        'quantity_on_hand',
        'quantity_reserved',
        'policy',
    ];

    protected function casts(): array
    {
        return [
            'policy' => InventoryPolicy::class,
            'quantity_on_hand' => 'integer',
            'quantity_reserved' => 'integer',
        ];
    }

    public function variant(): BelongsTo
    {
        return $this->belongsTo(ProductVariant::class, 'variant_id');
    }

    public function availableQuantity(): int
    {
        return $this->quantity_on_hand - $this->quantity_reserved;
    }
}
'''

models["Collection.php"] = r'''<?php

namespace App\Models;

use App\Enums\CollectionStatus;
use App\Enums\CollectionType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

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

    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'description_html',
        'type',
        'status',
    ];

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

    public function products(): BelongsToMany
    {
        return $this->belongsToMany(Product::class, 'collection_products')
            ->withPivot('position')
            ->orderByPivot('position');
    }
}
'''

models["ProductMedia.php"] = r'''<?php

namespace App\Models;

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

class ProductMedia extends Model
{
    /** @use HasFactory<\Database\Factories\ProductMediaFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $table = 'product_media';

    protected $fillable = [
        'product_id',
        'type',
        'storage_key',
        'alt_text',
        'width',
        'height',
        'mime_type',
        'byte_size',
        'position',
        'status',
        'created_at',
    ];

    protected function casts(): array
    {
        return [
            'type' => MediaType::class,
            'status' => MediaStatus::class,
            'created_at' => 'datetime',
        ];
    }

    protected static function booted(): void
    {
        static::creating(function (ProductMedia $media): void {
            if ($media->created_at === null) {
                $media->created_at = now();
            }
        });
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}
'''

models["Customer.php"] = r'''<?php

namespace App\Models;

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

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

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

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

    protected function casts(): array
    {
        return [
            'password' => 'hashed',
            'marketing_opt_in' => 'boolean',
        ];
    }

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

models["CustomerAddress.php"] = r'''<?php

namespace App\Models;

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

class CustomerAddress extends Model
{
    /** @use HasFactory<\Database\Factories\CustomerAddressFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'customer_id',
        'label',
        'address_json',
        'is_default',
    ];

    protected function casts(): array
    {
        return [
            'address_json' => 'array',
            'is_default' => 'boolean',
        ];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }
}
'''

models["User.php"] = r'''<?php

namespace App\Models;

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

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

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

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

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

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

    public function roleForStore(Store $store): ?StoreUserRole
    {
        $role = $this->stores()
            ->where('stores.id', $store->id)
            ->first()
            ?->pivot
            ?->role;

        if ($role instanceof StoreUserRole) {
            return $role;
        }

        return $role !== null ? StoreUserRole::from((string) $role) : null;
    }

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

for name, content in models.items():
    Path(f"app/Models/{name}").write_text(content)
    print("model", name)
PY

Write all Phase 1-2 Eloquent models


cd /Users/fabianwesner/Herd/shop
python3 << 'PY'
from pathlib import Path

# Factories
factories = {
"OrganizationFactory.php": r'''<?php

namespace Database\Factories;

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

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

    public function definition(): array
    {
        return [
            'name' => fake()->company(),
            'billing_email' => fake()->companyEmail(),
        ];
    }
}
''',
"StoreFactory.php": r'''<?php

namespace Database\Factories;

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

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

    public function definition(): array
    {
        $name = fake()->company().' Store';

        return [
            'organization_id' => Organization::factory(),
            'name' => $name,
            'handle' => Str::slug(fake()->unique()->words(2, true)),
            'status' => StoreStatus::Active,
            'default_currency' => 'EUR',
            'default_locale' => 'en',
            'timezone' => 'Europe/Berlin',
        ];
    }

    public function suspended(): static
    {
        return $this->state(fn (): array => [
            'status' => StoreStatus::Suspended,
        ]);
    }
}
''',
"StoreDomainFactory.php": r'''<?php

namespace Database\Factories;

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

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

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

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

    public function secondary(): static
    {
        return $this->state(fn (): array => [
            'is_primary' => false,
        ]);
    }
}
''',
"StoreSettingsFactory.php": r'''<?php

namespace Database\Factories;

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

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

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'settings_json' => [
                'contact_email' => fake()->companyEmail(),
            ],
            'updated_at' => now(),
        ];
    }
}
''',
"ProductFactory.php": r'''<?php

namespace Database\Factories;

use App\Enums\ProductStatus;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends Factory<Product>
 */
class ProductFactory extends Factory
{
    protected $model = Product::class;

    public function definition(): array
    {
        $title = fake()->words(3, true);

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title).'-'.fake()->unique()->numerify('###'),
            'status' => ProductStatus::Active,
            'description_html' => '
'.fake()->paragraph().'

'.fake()->paragraph().'
',
            'vendor' => fake()->company(),
            'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']),
            'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)),
            'published_at' => now(),
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (): array => [
            'status' => ProductStatus::Draft,
            'published_at' => null,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (): array => [
            'status' => ProductStatus::Archived,
        ]);
    }

    public function withDefaultVariant(int $price = 2499): static
    {
        return $this->afterCreating(function (Product $product) use ($price): void {
            ProductVariant::factory()->create([
                'product_id' => $product->id,
                'price_amount' => $price,
                'currency' => $product->store->default_currency,
                'is_default' => true,
            ]);
        });
    }
}
''',
"ProductVariantFactory.php": r'''<?php

namespace Database\Factories;

use App\Enums\VariantStatus;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<ProductVariant>
 */
class ProductVariantFactory extends Factory
{
    protected $model = ProductVariant::class;

    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'sku' => strtoupper(fake()->bothify('SKU-####-??')),
            'barcode' => fake()->ean13(),
            'price_amount' => fake()->numberBetween(500, 20000),
            'compare_at_amount' => null,
            'currency' => 'EUR',
            'weight_g' => fake()->numberBetween(100, 2000),
            'requires_shipping' => true,
            'is_default' => false,
            'position' => 0,
            'status' => VariantStatus::Active,
        ];
    }

    public function withInventory(int $onHand = 100): static
    {
        return $this->afterCreating(function (ProductVariant $variant) use ($onHand): void {
            InventoryItem::factory()->create([
                'store_id' => $variant->product->store_id,
                'variant_id' => $variant->id,
                'quantity_on_hand' => $onHand,
            ]);
        });
    }
}
''',
"ProductOptionFactory.php": r'''<?php

namespace Database\Factories;

use App\Models\Product;
use App\Models\ProductOption;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<ProductOption>
 */
class ProductOptionFactory extends Factory
{
    protected $model = ProductOption::class;

    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'name' => fake()->randomElement(['Size', 'Color', 'Material']),
            'position' => 0,
        ];
    }
}
''',
"ProductOptionValueFactory.php": r'''<?php

namespace Database\Factories;

use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<ProductOptionValue>
 */
class ProductOptionValueFactory extends Factory
{
    protected $model = ProductOptionValue::class;

    public function definition(): array
    {
        return [
            'product_option_id' => ProductOption::factory(),
            'value' => fake()->word(),
            'position' => 0,
        ];
    }
}
''',
"InventoryItemFactory.php": r'''<?php

namespace Database\Factories;

use App\Enums\InventoryPolicy;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<InventoryItem>
 */
class InventoryItemFactory extends Factory
{
    protected $model = InventoryItem::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'variant_id' => ProductVariant::factory(),
            'quantity_on_hand' => 100,
            'quantity_reserved' => 0,
            'policy' => InventoryPolicy::Deny,
        ];
    }
}
''',
"CollectionFactory.php": r'''<?php

namespace Database\Factories;

use App\Enums\CollectionStatus;
use App\Enums\CollectionType;
use App\Models\Collection;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends Factory<Collection>
 */
class CollectionFactory extends Factory
{
    protected $model = Collection::class;

    public function definition(): array
    {
        $title = fake()->words(2, true);

        return [
            'store_id' => Store::factory(),
            'title' => ucwords($title),
            'handle' => Str::slug($title).'-'.fake()->unique()->numerify('##'),
            'description_html' => '
'.fake()->sentence().'
',
            'type' => CollectionType::Manual,
            'status' => CollectionStatus::Active,
        ];
    }
}
''',
"ProductMediaFactory.php": r'''<?php

namespace Database\Factories;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Models\Product;
use App\Models\ProductMedia;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<ProductMedia>
 */
class ProductMediaFactory extends Factory
{
    protected $model = ProductMedia::class;

    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'type' => MediaType::Image,
            'storage_key' => 'products/'.fake()->uuid().'.jpg',
            'alt_text' => fake()->sentence(3),
            'width' => 1200,
            'height' => 1200,
            'mime_type' => 'image/jpeg',
            'byte_size' => 120000,
            'position' => 0,
            'status' => MediaStatus::Ready,
            'created_at' => now(),
        ];
    }
}
''',
"CustomerFactory.php": r'''<?php

namespace Database\Factories;

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

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

    protected static ?string $password;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'email' => fake()->unique()->safeEmail(),
            'password' => static::$password ??= Hash::make('password'),
            'name' => fake()->name(),
            'marketing_opt_in' => false,
        ];
    }

    public function guest(): static
    {
        return $this->state(fn (): array => [
            'password' => null,
        ]);
    }
}
''',
"CustomerAddressFactory.php": r'''<?php

namespace Database\Factories;

use App\Models\Customer;
use App\Models\CustomerAddress;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<CustomerAddress>
 */
class CustomerAddressFactory extends Factory
{
    protected $model = CustomerAddress::class;

    public function definition(): array
    {
        return [
            'customer_id' => Customer::factory(),
            'label' => fake()->randomElement(['Home', 'Work']),
            'address_json' => [
                'first_name' => fake()->firstName(),
                'last_name' => fake()->lastName(),
                'company' => '',
                'address1' => fake()->streetAddress(),
                'address2' => '',
                'city' => fake()->city(),
                'province' => 'Berlin',
                'province_code' => 'BE',
                'country' => 'Germany',
                'country_code' => 'DE',
                'zip' => fake()->postcode(),
                'phone' => fake()->phoneNumber(),
            ],
            'is_default' => true,
        ];
    }
}
'''
}

for name, content in factories.items():
    Path(f"database/factories/{name}").write_text(content)
    print("factory", name)

# Fix UserFactory status
uf = Path("database/factories/UserFactory.php")
text = uf.read_text()
if "'status'" not in text:
    text = text.replace(
        "'password' => static::$password ??= Hash::make('password'),",
        "'password' => static::$password ??= Hash::make('password'),\n            'status' => \\App\\Enums\\UserStatus::Active,\n            'last_login_at' => now()->subDays(fake()->numberBetween(0, 30)),",
    )
    uf.write_text(text)
    print("updated UserFactory")
PY

# Fix Store/User pivot timestamps
php -r '
$file = "app/Models/Store.php";
$c = file_get_contents($file);
$c = str_replace("->withPivot(\"role\")\n            ->withTimestamps();", "->withPivot(\"role\", \"created_at\");", $c);
file_put_contents($file, $c);
$file = "app/Models/User.php";
$c = file_get_contents($file);
$c = str_replace("->withPivot(\"role\")\n            ->withTimestamps();", "->withPivot(\"role\", \"created_at\");", $c);
file_put_contents($file, $c);
echo "fixed pivots\n";
'

Write factories and fix pivot timestamps


StrReplace

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

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

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

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

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

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

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

<?php

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

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

<?php

use App\Http\Middleware\ResolveStore;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias([
            'store.resolve' => ResolveStore::class,
        ]);

        $middleware->appendToGroup('storefront', [
            ResolveStore::class,
        ]);

        $middleware->appendToGroup('admin', [
            ResolveStore::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();

        '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' => null,
            'journal_mode' => null,
            'synchronous' => null,
            'transaction_mode' => 'DEFERRED',
        ],

        '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',
        ],

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

<?php

namespace App\Providers;

use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
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();
        $this->configureRateLimiting();
    }

    /**
     * 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
        );
    }

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


Shell

cd /Users/fabianwesner/Herd/shop

# Create policies
for p in Product Order Collection Discount Customer Store Page Theme Fulfillment Refund; do
  php artisan make:policy ${p}Policy --no-interaction
done

# Create support + services
php artisan make:class Support/HandleGenerator --no-interaction
php artisan make:class Services/ProductService --no-interaction
php artisan make:class Services/InventoryService --no-interaction
php artisan make:class Services/VariantMatrixService --no-interaction
php artisan make:class Exceptions/InsufficientInventoryException --no-interaction
php artisan make:class Exceptions/InvalidProductTransitionException --no-interaction

# Create tests
php artisan make:test --pest Tenancy/TenantResolutionTest --no-interaction
php artisan make:test --pest Tenancy/StoreIsolationTest --no-interaction
php artisan make:test --pest Products/InventoryTest --no-interaction
php artisan make:test --unit --pest HandleGeneratorTest --no-interaction

Scaffold policies, services, Pest tests


Write

<?php

namespace App\Policies\Concerns;

use App\Enums\StoreUserRole;
use App\Models\Store;
use App\Models\User;

trait ChecksStoreRole
{
    protected function currentStore(): ?Store
    {
        if (! app()->bound('current_store')) {
            return null;
        }

        $store = app('current_store');

        return $store instanceof Store ? $store : null;
    }

    protected function role(User $user): ?StoreUserRole
    {
        $store = $this->currentStore();

        if ($store === null) {
            return null;
        }

        return $user->roleForStore($store);
    }

    /**
     * @param  list<StoreUserRole>  $allowed
     */
    protected function hasRole(User $user, array $allowed): bool
    {
        $role = $this->role($user);

        return $role !== null && in_array($role, $allowed, true);
    }
}

Shell

cd /Users/fabianwesner/Herd/shop
python3 << 'PY'
from pathlib import Path

# Shared policy template
def policy(name, view_roles, manage_roles, delete_roles=None, extra=""):
    delete_roles = delete_roles or manage_roles
    view = ", ".join(f"StoreUserRole::{r}" for r in view_roles)
    manage = ", ".join(f"StoreUserRole::{r}" for r in manage_roles)
    delete = ", ".join(f"StoreUserRole::{r}" for r in delete_roles)
    model_import = f"use App\\Models\\{name};\n" if name not in ("Store",) else "use App\\Models\\Store;\n"
    model_param = f"{name} ${name.lower()}" if name != "Store" else "Store $store"
    # For Order etc model class may not exist yet - use Model or skip type
    return f'''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;
{model_import if Path(f"app/Models/{name}.php").exists() else ""}
class {name}Policy
{{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {{
        return $this->hasRole($user, [{view}]);
    }}

    public function view(User $user{(", "+model_param) if Path(f"app/Models/{name}.php").exists() else ""}): bool
    {{
        return $this->hasRole($user, [{view}]);
    }}

    public function create(User $user): bool
    {{
        return $this->hasRole($user, [{manage}]);
    }}

    public function update(User $user{(", "+model_param) if Path(f"app/Models/{name}.php").exists() else ""}): bool
    {{
        return $this->hasRole($user, [{manage}]);
    }}

    public function delete(User $user{(", "+model_param) if Path(f"app/Models/{name}.php").exists() else ""}): bool
    {{
        return $this->hasRole($user, [{delete}]);
    }}
{extra}}}
'''

# Simpler: write each policy manually without model typehints for missing models
policies = {
"ProductPolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\Product;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class ProductPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function view(User $user, Product $product): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function create(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Product $product): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Product $product): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
''',
"CollectionPolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\Collection;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class CollectionPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function view(User $user, Collection $collection): bool
    {
        return $this->viewAny($user);
    }

    public function create(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Collection $collection): bool
    {
        return $this->create($user);
    }

    public function delete(User $user, Collection $collection): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
''',
"CustomerPolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\Customer;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class CustomerPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]);
    }

    public function view(User $user, Customer $customer): bool
    {
        return $this->viewAny($user);
    }

    public function update(User $user, Customer $customer): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }
}
''',
"StorePolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\Store;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class StorePolicy
{
    use ChecksStoreRole;

    public function view(User $user, Store $store): bool
    {
        return $user->roleForStore($store) !== null;
    }

    public function update(User $user, Store $store): bool
    {
        $role = $user->roleForStore($store);

        return in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true);
    }

    public function delete(User $user, Store $store): bool
    {
        return $user->roleForStore($store) === StoreUserRole::Owner;
    }

    public function manageStaff(User $user, Store $store): bool
    {
        return $this->update($user, $store);
    }
}
''',
"OrderPolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class OrderPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]);
    }

    public function view(User $user, object $order): bool
    {
        return $this->viewAny($user);
    }

    public function update(User $user, object $order): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function refund(User $user, object $order): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function fulfill(User $user, object $order): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }
}
''',
"DiscountPolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class DiscountPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function view(User $user, object $discount): bool
    {
        return $this->viewAny($user);
    }

    public function create(User $user): bool
    {
        return $this->viewAny($user);
    }

    public function update(User $user, object $discount): bool
    {
        return $this->viewAny($user);
    }

    public function delete(User $user, object $discount): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
''',
"PagePolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class PagePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function create(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function update(User $user, object $page): bool
    {
        return $this->create($user);
    }

    public function delete(User $user, object $page): bool
    {
        return $this->create($user);
    }
}
''',
"ThemePolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class ThemePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function update(User $user, object $theme): bool
    {
        return $this->viewAny($user);
    }

    public function publish(User $user, object $theme): bool
    {
        return $this->viewAny($user);
    }
}
''',
"FulfillmentPolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class FulfillmentPolicy
{
    use ChecksStoreRole;

    public function create(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, object $fulfillment): bool
    {
        return $this->create($user);
    }
}
''',
"RefundPolicy": '''<?php

namespace App\\Policies;

use App\\Enums\\StoreUserRole;
use App\\Models\\User;
use App\\Policies\\Concerns\\ChecksStoreRole;

class RefundPolicy
{
    use ChecksStoreRole;

    public function create(User $user): bool
    {
        return $this->hasRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
''',
}

for name, content in policies.items():
    Path(f"app/Policies/{name}.php").write_text(content)
    print(name)
PY

Write role-based authorization policies


Write

<?php

namespace App\Support;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class HandleGenerator
{
    public function generate(string $title, string $table, int $storeId, ?int $excludeId = null): string
    {
        $base = Str::slug($title);

        if ($base === '') {
            $base = 'item';
        }

        $handle = $base;
        $suffix = 2;

        while ($this->exists($table, $storeId, $handle, $excludeId)) {
            $handle = $base.'-'.$suffix;
            $suffix++;
        }

        return $handle;
    }

    private function exists(string $table, int $storeId, string $handle, ?int $excludeId): bool
    {
        $query = DB::table($table)
            ->where('store_id', $storeId)
            ->where('handle', $handle);

        if ($excludeId !== null) {
            $query->where('id', '!=', $excludeId);
        }

        return $query->exists();
    }
}

<?php

namespace App\Exceptions;

use Exception;

class InsufficientInventoryException extends Exception
{
    public function __construct(string $message = 'Insufficient inventory available.')
    {
        parent::__construct($message);
    }
}

<?php

namespace App\Exceptions;

use Exception;

class InvalidProductTransitionException extends Exception
{
    public function __construct(string $message = 'Invalid product status transition.')
    {
        parent::__construct($message);
    }
}

<?php

namespace App\Services;

use App\Enums\InventoryPolicy;
use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use Illuminate\Support\Facades\DB;

class InventoryService
{
    public function checkAvailability(InventoryItem $item, int $quantity): bool
    {
        if ($item->policy === InventoryPolicy::Continue) {
            return true;
        }

        return $item->availableQuantity() >= $quantity;
    }

    public function reserve(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $locked = InventoryItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();

            if (! $this->checkAvailability($locked, $quantity)) {
                throw new InsufficientInventoryException;
            }

            $locked->quantity_reserved += $quantity;
            $locked->save();
        });
    }

    public function release(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $locked = InventoryItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
            $locked->quantity_reserved = max(0, $locked->quantity_reserved - $quantity);
            $locked->save();
        });
    }

    public function commit(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $locked = InventoryItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
            $locked->quantity_on_hand = max(0, $locked->quantity_on_hand - $quantity);
            $locked->quantity_reserved = max(0, $locked->quantity_reserved - $quantity);
            $locked->save();
        });
    }

    public function restock(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $locked = InventoryItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
            $locked->quantity_on_hand += $quantity;
            $locked->save();
        });
    }
}

<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Support\HandleGenerator;
use Illuminate\Support\Facades\DB;

class ProductService
{
    public function __construct(
        private HandleGenerator $handleGenerator,
    ) {}

    /**
     * @param  array<string, mixed>  $data
     */
    public function create(Store $store, array $data): Product
    {
        return DB::transaction(function () use ($store, $data): Product {
            $title = (string) ($data['title'] ?? 'Untitled');
            $handle = (string) ($data['handle'] ?? $this->handleGenerator->generate($title, 'products', $store->id));

            $product = Product::query()->create([
                'store_id' => $store->id,
                'title' => $title,
                'handle' => $handle,
                'status' => $data['status'] ?? ProductStatus::Draft,
                'description_html' => $data['description_html'] ?? null,
                'vendor' => $data['vendor'] ?? null,
                'product_type' => $data['product_type'] ?? null,
                'tags' => $data['tags'] ?? [],
                'published_at' => ($data['status'] ?? null) === ProductStatus::Active ? now() : null,
            ]);

            $variant = ProductVariant::query()->create([
                'product_id' => $product->id,
                'sku' => $data['sku'] ?? null,
                'price_amount' => (int) ($data['price_amount'] ?? 0),
                'currency' => $store->default_currency,
                'is_default' => true,
                'position' => 0,
                'status' => VariantStatus::Active,
            ]);

            InventoryItem::query()->create([
                'store_id' => $store->id,
                'variant_id' => $variant->id,
                'quantity_on_hand' => (int) ($data['quantity_on_hand'] ?? 0),
                'quantity_reserved' => 0,
            ]);

            return $product->fresh(['variants.inventoryItem']);
        });
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function update(Product $product, array $data): Product
    {
        if (isset($data['title']) && ! isset($data['handle'])) {
            $data['handle'] = $this->handleGenerator->generate(
                (string) $data['title'],
                'products',
                (int) $product->store_id,
                $product->id,
            );
        }

        $product->fill($data);
        $product->save();

        return $product->fresh();
    }

    public function transitionStatus(Product $product, ProductStatus $newStatus): void
    {
        $current = $product->status;

        if ($current === $newStatus) {
            return;
        }

        if ($newStatus === ProductStatus::Active) {
            if (trim($product->title) === '') {
                throw new InvalidProductTransitionException('Product title is required to publish.');
            }

            $hasPricedVariant = $product->variants()
                ->where('price_amount', '>', 0)
                ->exists();

            if (! $hasPricedVariant) {
                throw new InvalidProductTransitionException('A variant with price greater than zero is required to publish.');
            }

            if ($product->published_at === null) {
                $product->published_at = now();
            }
        }

        if ($newStatus === ProductStatus::Draft && in_array($current, [ProductStatus::Active, ProductStatus::Archived], true)) {
            throw new InvalidProductTransitionException('Cannot revert to draft when product has been published or archived.');
        }

        $product->status = $newStatus;
        $product->save();
    }

    public function delete(Product $product): void
    {
        if ($product->status !== ProductStatus::Draft) {
            throw new InvalidProductTransitionException('Only draft products can be deleted.');
        }

        $product->delete();
    }
}

<?php

namespace App\Services;

use App\Enums\VariantStatus;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class VariantMatrixService
{
    public function rebuildMatrix(Product $product): void
    {
        DB::transaction(function () use ($product): void {
            $product->load(['options.values', 'variants.optionValues']);

            $valueGroups = $product->options
                ->sortBy('position')
                ->map(fn ($option) => $option->values->sortBy('position')->values())
                ->values();

            if ($valueGroups->isEmpty()) {
                return;
            }

            $combinations = $this->cartesian($valueGroups);
            $existingKeys = [];

            foreach ($combinations as $index => $valueIds) {
                $key = collect($valueIds)->sort()->implode('-');
                $existingKeys[] = $key;

                $variant = $product->variants->first(function (ProductVariant $variant) use ($valueIds): bool {
                    $current = $variant->optionValues->pluck('id')->sort()->values()->all();
                    $expected = collect($valueIds)->sort()->values()->all();

                    return $current === $expected;
                });

                if ($variant === null) {
                    $variant = ProductVariant::query()->create([
                        'product_id' => $product->id,
                        'price_amount' => 0,
                        'currency' => $product->store->default_currency,
                        'is_default' => $index === 0,
                        'position' => $index,
                        'status' => VariantStatus::Active,
                    ]);

                    $variant->optionValues()->sync($valueIds);

                    InventoryItem::query()->create([
                        'store_id' => $product->store_id,
                        'variant_id' => $variant->id,
                        'quantity_on_hand' => 0,
                        'quantity_reserved' => 0,
                    ]);
                } else {
                    $variant->update([
                        'position' => $index,
                        'status' => VariantStatus::Active,
                        'is_default' => $index === 0,
                    ]);
                }
            }

            foreach ($product->variants as $variant) {
                $key = $variant->optionValues->pluck('id')->sort()->implode('-');

                if ($key !== '' && ! in_array($key, $existingKeys, true)) {
                    $variant->update(['status' => VariantStatus::Archived]);
                }
            }
        });
    }

    /**
     * @param  Collection<int, Collection<int, mixed>>  $groups
     * @return list<list<int>>
     */
    private function cartesian(Collection $groups): array
    {
        $result = [[]];

        foreach ($groups as $group) {
            $append = [];

            foreach ($result as $product) {
                foreach ($group as $item) {
                    $append[] = array_merge($product, [$item->id]);
                }
            }

            $result = $append;
        }

        return $result;
    }
}