Skip to content

Multi-Market Implementation: Phase 6 - EasyAdmin Controllers

Status: Planning

Dependencies

Requires completed:

  • Phase 1 (Entities) - Market, AffiliateProgram, BuyingOption entities
  • Phase 2 (Migration) - Database schema ready
  • Phase 3 (Services) - Validation services available
  • Phase 4 (Cache/Repository) - Cache invalidation works
  • Phase 5 (API Integration) - DTOs and services tested

Blocks:

  • Phase 7 (Matrix UI) - Optional bean-centric buying options UI

📋 Todo Checklist

  • [ ] Create MarketCrudController with shipping region selector
  • [ ] Create AffiliateProgramCrudController with template validation
  • [ ] Create BuyingOptionCrudController for manual overrides
  • [ ] Update RoasterCrawlConfigCrudController with market and priority fields
  • [ ] Update CrawlUrlCrudController with available field
  • [ ] Add navigation menu section for Markets & Monetization
  • [ ] Create form types for complex fields
  • [ ] Add entity validation in controllers
  • [ ] Create admin user guide documentation
  • [ ] Test all CRUD operations
  • [ ] Test form validation

🔍 Analysis & Investigation

Problem Statement

Admins need EasyAdmin interfaces to manage the multi-market system:

  • Create and configure markets with countries
  • Set up affiliate programs with Twig templates
  • Create manual URL overrides (buying options)
  • Assign roaster configs to markets with priorities
  • Manage per-URL availability (mark URLs as available/unavailable)

Current Architecture

Existing EasyAdmin Controllers:

  • RoasterCrawlConfigCrudController - Has shipping region selector pattern
  • Various other CRUD controllers

Patterns to Reuse:

  • Shipping region selector (from RoasterCrawlConfigCrudController)
  • TomSelect for autocomplete relationships
  • FormField::addFieldset() for field grouping
  • Custom formatters for display

Target Architecture

Five new/updated controllers:

  1. MarketCrudController - Manage consumer markets
  2. AffiliateProgramCrudController - Manage affiliate programs
  3. BuyingOptionCrudController - Manage manual URL overrides
  4. RoasterCrawlConfigCrudController - Update with market/priority fields
  5. CrawlUrlCrudController - Update with available field for per-URL availability

Admin workflow:

  1. Create AffiliateProgram
  2. Create Market (assign countries + affiliate)
  3. Update RoasterCrawlConfigs (assign to market, set priority)
  4. Optionally create BuyingOptions for special cases

📝 Implementation Plan

Prerequisites

  • Phase 1-5 completed
  • EasyAdmin bundle configured
  • Shipping configuration JavaScript available (shipping-configuration.js)

Step-by-Step Implementation

Step 1: Create MarketCrudController

File: src/Controller/Admin/MarketCrudController.php

<?php

namespace App\Controller\Admin;

use App\Entity\Market;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;

class MarketCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Market::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->setEntityLabelInSingular('Market')
            ->setEntityLabelInPlural('Markets')
            ->setSearchFields(['name', 'notes'])
            ->setDefaultSort(['name' => 'ASC'])
            ->setPageTitle('index', 'Markets & Consumer Regions')
            ->setPageTitle('new', 'Create New Market')
            ->setPageTitle('edit', 'Edit Market: %entity_label_singular%')
            ->setPageTitle('detail', '%entity_label_singular%');
    }

    public function configureActions(Actions $actions): Actions
    {
        return $actions
            ->add(Crud::PAGE_INDEX, Action::DETAIL)
            ->setPermission(Action::DELETE, 'ROLE_ADMIN');
    }

    public function configureFields(string $pageName): iterable
    {
        yield IdField::new('id')
            ->onlyOnDetail();

        yield FormField::addFieldset('Basic Information');

        yield TextField::new('name')
            ->setRequired(true)
            ->setHelp('Display name for this market (e.g., "United States", "European Union", "Germany - Amazon")');

        yield BooleanField::new('isActive')
            ->setHelp('Enable or disable this market without deleting it');

        yield FormField::addFieldset('Geographic Coverage')
            ->setHelp('Select which countries this market serves using the shipping region selector below');

        // Reuse shipping region selector pattern from RoasterCrawlConfigCrudController
        yield ChoiceField::new('shippingRegion', 'Shipping Region')
            ->setChoices([
                'European Union' => 'EU',
                'North America' => 'NA',
                'Asia' => 'ASIA',
                'Worldwide' => 'WORLDWIDE',
                'Custom Selection' => 'CUSTOM',
            ])
            ->hideOnIndex()
            ->setFormTypeOption('mapped', false)
            ->setHelp('Quick select a region, then refine with exception countries below');

        yield AssociationField::new('countries')
            ->setFormTypeOption('by_reference', false) // CRITICAL for ManyToMany
            ->setFormTypeOption('multiple', true)
            ->setRequired(true)
            ->hideOnIndex()
            ->setHelp('Countries where this market serves customers (at least one required)');

        // Display-only field for index/detail
        yield CollectionField::new('countries')
            ->onlyOnIndex()
            ->formatValue(function ($value, Market $market) {
                $countries = $market->getCountries()->toArray();
                if (count($countries) > 5) {
                    $first5 = array_slice($countries, 0, 5);
                    $names = array_map(fn($c) => $c->getName(), $first5);
                    return multi-market-06-easyadmin.mdimplode(', ', $names) . sprintf(' (+%d more)', count($countries) - 5);
                }
                $names = array_map(fn($c) => $c->getName(), $countries);
                return implode(', ', $names);
            });

        yield FormField::addFieldset('Monetization');

        yield AssociationField::new('affiliateProgram')
            ->setHelp('Affiliate program for monetizing purchases in this market (optional)')
            ->autocomplete();

        yield FormField::addFieldset('Notes');

        yield TextareaField::new('notes')
            ->hideOnIndex()
            ->setHelp('Internal notes about this market (not shown to customers)');

        // Display metadata
        if ($pageName === Crud::PAGE_DETAIL) {
            yield FormField::addFieldset('System Information');
            yield TextField::new('createdAt')
                ->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
            yield TextField::new('updatedAt')
                ->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
        }
    }
}

JavaScript Asset: Reuse shipping-configuration.js from RoasterCrawlConfigCrudController for the shipping region selector.

Step 2: Create AffiliateProgramCrudController

File: src/Controller/Admin/AffiliateProgramCrudController.php

<?php

namespace App\Controller\Admin;

use App\Entity\AffiliateProgram;
use App\Enum\AffiliateProvider;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CodeEditorField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;

class AffiliateProgramCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return AffiliateProgram::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->setEntityLabelInSingular('Affiliate Program')
            ->setEntityLabelInPlural('Affiliate Programs')
            ->setSearchFields(['name', 'affiliateId', 'notes'])
            ->setDefaultSort(['name' => 'ASC'])
            ->setPageTitle('index', 'Affiliate Programs')
            ->setPageTitle('new', 'Create New Affiliate Program')
            ->setPageTitle('edit', 'Edit Affiliate Program: %entity_label_singular%')
            ->setPageTitle('detail', '%entity_label_singular%');
    }

    public function configureActions(Actions $actions): Actions
    {
        return $actions
            ->add(Crud::PAGE_INDEX, Action::DETAIL)
            ->setPermission(Action::DELETE, 'ROLE_ADMIN');
    }

    public function configureFields(string $pageName): iterable
    {
        yield IdField::new('id')
            ->onlyOnDetail();

        yield FormField::addFieldset('Basic Configuration');

        yield TextField::new('name')
            ->setRequired(true)
            ->setHelp('Display name (e.g., "Impact US", "Amazon Associates CA")');

        yield ChoiceField::new('provider')
            ->setChoices([
                'Impact' => AffiliateProvider::IMPACT->value,
                'AWIN' => AffiliateProvider::AWIN->value,
                'Partnerize' => AffiliateProvider::PARTNERIZE->value,
                'Amazon Associates' => AffiliateProvider::AMAZON_ASSOCIATES->value,
                'Custom' => AffiliateProvider::CUSTOM->value,
            ])
            ->setRequired(true)
            ->setHelp('Affiliate network provider');

        yield TextField::new('affiliateId')
            ->setRequired(true)
            ->setHelp('Your affiliate ID / tag / account identifier');

        yield BooleanField::new('isActive')
            ->setHelp('Enable or disable this program without deleting it');

        yield FormField::addFieldset('Advanced Configuration')
            ->setHelp('⚠️ Advanced users only. Leave empty to use hard-coded patterns for standard providers.');

        yield CodeEditorField::new('urlPattern', 'URL Pattern (Twig Template)')
            ->setLanguage('twig')
            ->hideOnIndex()
            ->setHelp('Twig template for URL transformation. Required for CUSTOM provider. Available variables: {{ url }}, {{ affiliateId }}, and any from parameters JSON below.')
            ->setFormTypeOption('attr', ['rows' => 5]);

        yield CodeEditorField::new('parameters', 'Parameters (JSON)')
            ->setLanguage('json')
            ->hideOnIndex()
            ->setHelp('Additional template variables as JSON object (e.g., {"awinmid": "12345"}). Reserved keys: url, affiliateId.')
            ->setFormTypeOption('attr', ['rows' => 5]);

        yield FormField::addFieldset('Notes');

        yield TextareaField::new('notes')
            ->hideOnIndex()
            ->setHelp('Internal notes about this program');

        // Display markets using this program on detail page
        if ($pageName === Crud::PAGE_DETAIL) {
            yield FormField::addFieldset('Markets Using This Program');
            yield TextField::new('markets')
                ->formatValue(function ($value, AffiliateProgram $program) {
                    $markets = $program->getMarkets()->toArray();
                    if (empty($markets)) {
                        return 'No markets assigned';
                    }
                    $names = array_map(fn($m) => $m->getName(), $markets);
                    return implode(', ', $names);
                });
        }

        // Display metadata
        if ($pageName === Crud::PAGE_DETAIL) {
            yield FormField::addFieldset('System Information');
            yield TextField::new('createdAt')
                ->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
            yield TextField::new('updatedAt')
                ->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
        }
    }
}

Validation Help Text Examples:

Add help text showing example patterns:

yield CodeEditorField::new('urlPattern', 'URL Pattern (Twig Template)')
    ->setHelp('
        Examples:
        - Amazon: {{ url }}{% if "?" in url %}&{% else %}?{% endif %}tag={{ affiliateId }}
        - Custom: {{ url }}?ref={{ affiliateId }}&utm_source={{ source }}

        Available variables: {{ url }}, {{ affiliateId }}, + any from parameters JSON
    ');

Step 3: Create BuyingOptionCrudController

File: src/Controller/Admin/BuyingOptionCrudController.php

<?php

namespace App\Controller\Admin;

use App\Entity\BuyingOption;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField;

class BuyingOptionCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return BuyingOption::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->setEntityLabelInSingular('Buying Option')
            ->setEntityLabelInPlural('Buying Options (Manual Overrides)')
            ->setSearchFields(['coffeeBean.name', 'market.name', 'urlOverride'])
            ->setDefaultSort(['updatedAt' => 'DESC'])
            ->setPageTitle('index', 'Manual Buying Options')
            ->setPageTitle('new', 'Create Manual Buying Option')
            ->setPageTitle('edit', 'Edit Buying Option')
            ->setPageTitle('detail', '%entity_label_singular%')
            ->setHelp('index', 'Manual URL overrides for specific bean-market combinations. Only create for exceptions (Amazon links, special sellers, etc.).');
    }

    public function configureActions(Actions $actions): Actions
    {
        return $actions
            ->add(Crud::PAGE_INDEX, Action::DETAIL)
            ->setPermission(Action::DELETE, 'ROLE_ADMIN');
    }

    public function configureFields(string $pageName): iterable
    {
        yield IdField::new('id')
            ->onlyOnDetail();

        yield FormField::addFieldset('Manual Override Configuration')
            ->setHelp('⚠️ Only create buying options when you need to override the automatic URL resolution. Most beans work automatically.');

        yield AssociationField::new('coffeeBean')
            ->setRequired(true)
            ->autocomplete()
            ->setHelp('Select the coffee bean');

        yield AssociationField::new('market')
            ->setRequired(true)
            ->autocomplete()
            ->setHelp('Select the market for this override');

        yield UrlField::new('urlOverride')
            ->setRequired(true)
            ->setHelp('Manual URL override (e.g., Amazon product page, alternative seller). REQUIRED - leave empty to use automatic resolution.')
            ->setFormTypeOption('attr', ['placeholder' => 'https://example.com/product']);

        yield FormField::addFieldset('Notes');

        yield TextareaField::new('notes')
            ->hideOnIndex()
            ->setHelp('Why is this manual override needed? (e.g., "Available on Amazon DE", "Special arrangement with Roastmarket")');

        // Display metadata
        if ($pageName === Crud::PAGE_DETAIL) {
            yield FormField::addFieldset('System Information');
            yield TextField::new('createdAt')
                ->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
            yield TextField::new('updatedAt')
                ->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
        }
    }
}

Note: The bean-centric matrix UI (Phase 7) will provide a better interface for managing buying options. This CRUD controller is the backup/advanced interface.

Step 4: Update RoasterCrawlConfigCrudController

File: src/Controller/Admin/RoasterCrawlConfigCrudController.php (modify existing)

Add market and priority fields:

public function configureFields(string $pageName): iterable
{
    // ... existing fields ...

    yield FormField::addFieldset('Market Configuration')
        ->setHelp('Assign this config to a market and set its priority for URL selection');

    yield AssociationField::new('market')
        ->autocomplete()
        ->setHelp('Consumer market this config serves (optional during transition, assign for multi-market support)');

    yield NumberField::new('priority')
        ->setHelp('Priority for URL selection (0-1000). Higher priority wins when multiple configs serve the same country. Default: 50')
        ->setFormTypeOption('attr', [
            'min' => 0,
            'max' => 1000,
            'step' => 10,
        ]);

    // ... existing fields (shippingRegion, shipsTo, etc.) ...

    yield FormField::addFieldset('Shipping Configuration')
        ->setHelp('⚠️ Note: During transition, both shipping and market countries are checked. Eventually, market countries will be primary.');

    // ... existing shipping fields ...
}

Notes:

  • Keep existing shippingRegion and shipsTo fields (serve different purpose)
  • Add new fieldset for market configuration
  • Default priority = 50

Step 5: Update CrawlUrlCrudController

File: src/Controller/Admin/CrawlUrlCrudController.php (modify existing)

Add available field to allow admins to mark URLs as available/unavailable:

public function configureFields(string $pageName): iterable
{
    yield IdField::new('id')->onlyOnDetail();

    yield UrlField::new('url')
        ->formatValue(function ($value, $entity) {
            return mb_strlen($value) > 100 ? mb_substr($value, 0, 100) . '...' : $value;
        });

    yield AssociationField::new('roasterCrawlConfig')
        ->setFormTypeOption('disabled', $pageName !== Crud::PAGE_NEW)
        ->hideOnIndex();

    // NEW: Available field
    yield BooleanField::new('available')
        ->setHelp('Marks whether this URL is available for purchase. Unavailable URLs will be filtered from API responses.')
        ->renderAsSwitch(true);

    yield NumberField::new('contentConfidence')
        ->setHelp('Percentage confidence that this URL points to a coffee bean product')
        ->setNumDecimals(1);

    yield BooleanField::new('success')
        ->renderAsSwitch(false);

    // ... rest of existing fields ...
}

Filter Addition:

Add filter for available field:

public function configureFilters(Filters $filters): Filters
{
    return $filters
        ->add(EntityFilter::new('roasterCrawlConfig'))
        ->add(BooleanFilter::new('available')) // NEW
        ->add(NumericFilter::new('contentConfidence'))
        ->add(BooleanFilter::new('success'))
        ->add(TextFilter::new('url'))
        ->add(DateTimeFilter::new('lastCrawled'))
        ->add(TextFilter::new('failReason'));
}

Notes:

  • Display available as a switch for quick toggling
  • Add filter to quickly find unavailable URLs
  • Help text explains the field's purpose in URL resolution
  • Position after roasterCrawlConfig association for logical grouping

Step 6: Update DashboardController Navigation

File: src/Controller/Admin/DashboardController.php (modify existing)

Add new menu section:

public function configureMenuItems(): iterable
{
    yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');

    // ... existing menu items ...

    // NEW: Markets & Monetization section
    yield MenuItem::section('Markets & Monetization');

    yield MenuItem::linkToCrud('Markets', 'fa fa-globe', Market::class);

    yield MenuItem::linkToCrud('Affiliate Programs', 'fa fa-handshake', AffiliateProgram::class);

    yield MenuItem::linkToCrud('Buying Options', 'fa fa-link', BuyingOption::class)
        ->setBadge('Manual Overrides', 'warning');

    // ... existing menu items continue ...
}

Menu order suggestion:

  1. Dashboard
  2. Coffee Bean Management
  3. Markets & Monetization (NEW)
  4. Queue & Crawler Management
  5. System Configuration

Step 7: Create Admin User Guide

File: docs/admin-guide-multi-market.md

# Multi-Market System Admin Guide

## Overview

The multi-market system allows you to serve different URLs and affiliate programs based on visitor location.

## Setup Workflow

### 1. Create Affiliate Programs

Navigate to **Markets & Monetization → Affiliate Programs**

**Example: Amazon Associates US**
- Name: "Amazon Associates US"
- Provider: Amazon Associates
- Affiliate ID: "beanb-20"
- URL Pattern: Leave empty (uses hard-coded pattern)

**Example: Custom Direct Affiliate**
- Name: "Direct Partner Program"
- Provider: Custom
- Affiliate ID: "partner-id"
- URL Pattern: `{{ url }}?ref={{ affiliateId }}&utm_source=beanb`

### 2. Create Markets

Navigate to **Markets & Monetization → Markets**

**Example: US Direct**
- Name: "United States - Direct"
- Shipping Region: North America
- Countries: Select USA (or use region selector)
- Affiliate Program: Select "Amazon Associates US"

**Example: EU Market**
- Name: "European Union"
- Shipping Region: European Union
- Countries: Auto-populated, remove exceptions if needed
- Affiliate Program: Select your EU affiliate program

### 3. Assign Configs to Markets

Navigate to **Queue & Crawler Management → Roaster Crawl Configs**

For each config:
- Select Market (e.g., "US Direct")
- Set Priority (50 = default, higher = preferred)

**Priority Examples:**
- Roaster's main site: Priority 100
- Amazon fallback: Priority 50
- Third-party seller: Priority 30

### 4. Create Manual Overrides (Optional)

Navigate to **Markets & Monetization → Buying Options**

Only create when needed:
- Bean available on alternative marketplace (Amazon)
- Special seller arrangement
- Temporarily disable for specific market

**Example:**
- Coffee Bean: "Ethiopian Yirgacheffe"
- Market: "Germany - Amazon"
- URL Override: "https://amazon.de/dp/B08XYZ"

### 5. Manage URL Availability (Optional)

Navigate to **Queue & Crawler Management → Crawl URLs**

Use the `available` field to control per-URL availability:

**When to mark a URL as unavailable:**
- Product is out of stock
- URL is broken/404
- Roaster discontinued the product
- Temporary unavailability

**How to manage:**
1. Filter by roaster config or search for specific URL
2. Toggle the "Available" switch on/off
3. Use the "Available" filter to find all unavailable URLs

**Note:** Unavailable URLs are automatically excluded from API responses, so customers won't see purchase links for them.

## How URL Selection Works

For each visitor, the system checks:

1. **Availability Check** → Filter out URLs where `available = false`
2. **Manual Override?** → Use BuyingOption.urlOverride if exists
3. **Priority Selection** → Find configs serving visitor's country, pick highest priority
4. **Affiliate Transform** → Apply market's affiliate program to URL
5. **Return URL** → Visitor sees affiliate-tracked purchase link

## Testing

After setup, test with different countries:
1. Open API in browser: `/api/coffee-beans?visitorCountryId=DE`
2. Check `url` field has affiliate tracking
3. Verify correct URL returned for each country

## Troubleshooting

**Bean not showing for country:**
- Check RoasterCrawlConfig.market serves that country
- Check CrawlUrl.available = true
- Check Market.isActive = true
- Check AffiliateProgram.isActive = true (if used)

**Wrong URL returned:**
- Check priorities (higher wins)
- Check for manual BuyingOption override
- Verify market countries include visitor's country

Testing Strategy

Manual Testing

  1. MarketCrudController:

    • Create market with shipping region selector
    • Verify countries auto-populate
    • Test adding/removing countries
    • Test ManyToMany persistence (by_reference: false)
    • Test validation (at least 1 country required)
  2. AffiliateProgramCrudController:

    • Create program for each provider type
    • Test CUSTOM provider requires urlPattern
    • Test reserved parameter validation
    • Test JSON parameter validation
    • Verify markets list shows on detail page
  3. BuyingOptionCrudController:

    • Create manual override
    • Test unique constraint (bean + market)
    • Test URL validation
    • Verify urlOverride is required
  4. RoasterCrawlConfigCrudController:

    • Add market to existing config
    • Set priority
    • Verify form saves correctly
  5. CrawlUrlCrudController:

    • Toggle available field on/off
    • Verify switch UI works correctly
    • Test available filter (show only unavailable URLs)
    • Verify available field displays on index and detail pages
    • Test that unavailable URLs are excluded from API responses

Integration Testing

  1. Full Workflow:

    • Create AffiliateProgram
    • Create Market with countries
    • Create RoasterCrawlConfig with market
    • Verify bean shows in API for those countries
  2. Form Validation:

    • Test entity-level constraints
    • Test unique constraints
    • Test required fields

🎯 Success Criteria

  • All five CRUD controllers created/updated (MarketCrudController, AffiliateProgramCrudController, BuyingOptionCrudController, RoasterCrawlConfigCrudController, CrawlUrlCrudController)
  • Navigation menu includes Markets & Monetization section
  • Shipping region selector works for market countries (ManyToMany)
  • Affiliate program validation prevents invalid templates
  • BuyingOption enforces unique constraint
  • RoasterCrawlConfig includes market and priority fields
  • CrawlUrl includes available field with switch UI and filter
  • All CRUD operations work (create, read, update, delete)
  • Form validation prevents invalid data
  • Help text guides admins through configuration
  • Admin guide documentation complete
  • All manual tests pass

Files to Create:

  • src/Controller/Admin/MarketCrudController.php
  • src/Controller/Admin/AffiliateProgramCrudController.php
  • src/Controller/Admin/BuyingOptionCrudController.php
  • docs/admin-guide-multi-market.md

Files to Modify:

  • src/Controller/Admin/RoasterCrawlConfigCrudController.php
  • src/Controller/Admin/CrawlUrlCrudController.php
  • src/Controller/Admin/DashboardController.php

Files to Reuse:

  • assets/admin/shipping-configuration.js (for shipping region selector)

Files Referenced:

  • src/Entity/Market.php
  • src/Entity/AffiliateProgram.php
  • src/Entity/BuyingOption.php
  • src/Entity/RoasterCrawlConfig.php
  • src/Entity/CrawlUrl.php

Next Steps

After completing this phase:

  • Proceed to Phase 7 (Matrix UI) for bean-centric buying options interface (optional)
  • Admin can now configure entire multi-market system
  • Ready for production use