Multi-Market Implementation: Phase 1 - Core Entities and Validation¶
Status: Planning¶
📋 Todo Checklist¶
- [ ] Create
Marketentity with country relationships - [ ] Create
AffiliateProgramentity with Twig template support - [ ] Create
BuyingOptionentity with unique constraint - [ ] Add validation constraints to all new entities
- [ ] Create database migration for new tables and indexes
- [ ] Create unit tests for entity validation
- [ ] Update doctrine schema and verify migrations
🔍 Analysis & Investigation¶
Problem Statement¶
The current system lacks multi-market support for handling different affiliate programs and buying options across geographic regions. Each coffee bean needs to support: - Different URLs for different markets (US Direct, US Amazon, EU, etc.) - Market-specific affiliate transformations - Manual overrides for special cases (Amazon links, Roastmarket.de, etc.)
Current Architecture¶
The system currently uses:
- RoasterCrawlConfig.shipsTo for geographic coverage
- Direct URLs without affiliate transformation
- Global CoffeeBean.available flag (to be replaced)
Target Architecture¶
Three new entities form the foundation of multi-market support: 1. Market - Consumer markets with geographic coverage and affiliate programs 2. AffiliateProgram - URL transformation templates using Twig 3. BuyingOption - Sparse manual URL overrides per bean-market combination
Dependencies¶
Prerequisites: - None - this is phase 1
Blocked by this phase: - Phase 2 (Migration) - needs entities to exist - Phase 3 (Services) - needs entities to exist - All subsequent phases
📝 Implementation Plan¶
Prerequisites¶
- Doctrine ORM configured
- Symfony validation component available
- Existing entities:
Country,CoffeeBean,RoasterCrawlConfig
Step-by-Step Implementation¶
Step 1: Create Market Entity¶
File: src/Entity/Market.php
Create entity representing consumer markets with geographic coverage:
<?php
namespace App\Entity;
use App\Repository\MarketRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MarketRepository::class)]
#[ORM\Table(name: 'market')]
#[ORM\Index(name: 'idx_market_active', columns: ['is_active'])]
#[ORM\HasLifecycleCallbacks]
class Market
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Assert\NotBlank(message: 'Market name is required.')]
#[Assert\Length(max: 255)]
private string $name;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
private bool $isActive = true;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $notes = null;
#[ORM\ManyToMany(targetEntity: Country::class)]
#[ORM\JoinTable(name: 'market_country')]
#[Assert\Count(
min: 1,
minMessage: 'At least one country must be assigned to the market.'
)]
private Collection $countries;
#[ORM\ManyToOne(targetEntity: AffiliateProgram::class, inversedBy: 'markets')]
#[ORM\JoinColumn(name: 'affiliate_program_id', referencedColumnName: 'id', nullable: true)]
private ?AffiliateProgram $affiliateProgram = null;
#[ORM\OneToMany(mappedBy: 'market', targetEntity: RoasterCrawlConfig::class)]
private Collection $roasterCrawlConfigs;
#[ORM\OneToMany(mappedBy: 'market', targetEntity: BuyingOption::class, cascade: ['remove'])]
private Collection $buyingOptions;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $updatedAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->countries = new ArrayCollection();
$this->roasterCrawlConfigs = new ArrayCollection();
$this->buyingOptions = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function preUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): self
{
$this->isActive = $isActive;
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): self
{
$this->notes = $notes;
return $this;
}
public function getCountries(): Collection
{
return $this->countries;
}
public function addCountry(Country $country): self
{
if (!$this->countries->contains($country)) {
$this->countries->add($country);
}
return $this;
}
public function removeCountry(Country $country): self
{
$this->countries->removeElement($country);
return $this;
}
public function getAffiliateProgram(): ?AffiliateProgram
{
return $this->affiliateProgram;
}
public function setAffiliateProgram(?AffiliateProgram $affiliateProgram): self
{
$this->affiliateProgram = $affiliateProgram;
return $this;
}
public function getRoasterCrawlConfigs(): Collection
{
return $this->roasterCrawlConfigs;
}
public function getBuyingOptions(): Collection
{
return $this->buyingOptions;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function __toString(): string
{
return $this->name;
}
}
Step 2: Create AffiliateProvider Enum¶
File: src/Enum/AffiliateProvider.php
<?php
namespace App\Enum;
enum AffiliateProvider: string
{
case IMPACT = 'impact';
case AWIN = 'awin';
case PARTNERIZE = 'partnerize';
case AMAZON_ASSOCIATES = 'amazon_associates';
case CUSTOM = 'custom';
public function getLabel(): string
{
return match($this) {
self::IMPACT => 'Impact',
self::AWIN => 'AWIN',
self::PARTNERIZE => 'Partnerize',
self::AMAZON_ASSOCIATES => 'Amazon Associates',
self::CUSTOM => 'Custom',
};
}
}
Step 3: Create AffiliateProgram Entity¶
File: src/Entity/AffiliateProgram.php
<?php
namespace App\Entity;
use App\Enum\AffiliateProvider;
use App\Repository\AffiliateProgramRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: AffiliateProgramRepository::class)]
#[ORM\Table(name: 'affiliate_program')]
#[ORM\Index(name: 'idx_affiliate_active', columns: ['is_active'])]
#[ORM\Index(name: 'idx_affiliate_provider', columns: ['provider'])]
#[ORM\HasLifecycleCallbacks]
class AffiliateProgram
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Assert\NotBlank(message: 'Program name is required.')]
#[Assert\Length(max: 255)]
private string $name;
#[ORM\Column(type: Types::STRING, enumType: AffiliateProvider::class)]
#[Assert\NotNull]
private AffiliateProvider $provider;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Assert\NotBlank(message: 'Affiliate ID is required.')]
#[Assert\Length(max: 255)]
private string $affiliateId;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Assert\Length(max: 2000, maxMessage: 'URL pattern too long (max 2000 chars)')]
private ?string $urlPattern = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $parameters = null;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
private bool $isActive = true;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $notes = null;
#[ORM\OneToMany(mappedBy: 'affiliateProgram', targetEntity: Market::class)]
private Collection $markets;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $updatedAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->markets = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function preUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
#[Assert\Callback]
public function validateUrlPattern(ExecutionContextInterface $context): void
{
if ($this->provider === AffiliateProvider::CUSTOM && empty($this->urlPattern)) {
$context->buildViolation('URL Pattern is required for CUSTOM provider.')
->atPath('urlPattern')
->addViolation();
}
}
#[Assert\Callback]
public function validateParameters(ExecutionContextInterface $context): void
{
if ($this->parameters === null) {
return;
}
// Validate reserved variables
$reserved = ['url', 'affiliateId'];
foreach (array_keys($this->parameters) as $key) {
if (in_array($key, $reserved, true)) {
$context->buildViolation('Parameter "{{ key }}" is reserved and cannot be used.')
->setParameter('{{ key }}', $key)
->atPath('parameters')
->addViolation();
}
}
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getProvider(): AffiliateProvider
{
return $this->provider;
}
public function setProvider(AffiliateProvider $provider): self
{
$this->provider = $provider;
return $this;
}
public function getAffiliateId(): string
{
return $this->affiliateId;
}
public function setAffiliateId(string $affiliateId): self
{
$this->affiliateId = $affiliateId;
return $this;
}
public function getUrlPattern(): ?string
{
return $this->urlPattern;
}
public function setUrlPattern(?string $urlPattern): self
{
$this->urlPattern = $urlPattern;
return $this;
}
public function getParameters(): ?array
{
return $this->parameters;
}
public function setParameters(?array $parameters): self
{
$this->parameters = $parameters;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): self
{
$this->isActive = $isActive;
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): self
{
$this->notes = $notes;
return $this;
}
public function getMarkets(): Collection
{
return $this->markets;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function __toString(): string
{
return $this->name;
}
}
Step 4: Create BuyingOption Entity¶
File: src/Entity/BuyingOption.php
<?php
namespace App\Entity;
use App\Repository\BuyingOptionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BuyingOptionRepository::class)]
#[ORM\Table(name: 'buying_option')]
#[ORM\Index(name: 'idx_buying_option_bean', columns: ['coffee_bean_id'])]
#[ORM\UniqueConstraint(name: 'uniq_buying_option_bean_market', columns: ['coffee_bean_id', 'market_id'])]
#[ORM\HasLifecycleCallbacks]
class BuyingOption
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: CoffeeBean::class)]
#[ORM\JoinColumn(name: 'coffee_bean_id', referencedColumnName: 'id', nullable: false)]
#[Assert\NotNull(message: 'Coffee bean is required.')]
private CoffeeBean $coffeeBean;
#[ORM\ManyToOne(targetEntity: Market::class, inversedBy: 'buyingOptions')]
#[ORM\JoinColumn(name: 'market_id', referencedColumnName: 'id', nullable: false)]
#[Assert\NotNull(message: 'Market is required.')]
private Market $market;
#[ORM\Column(type: Types::STRING, length: 512)]
#[Assert\NotBlank(message: 'URL override is required for manual buying options.')]
#[Assert\Url(message: 'URL override must be a valid URL.')]
#[Assert\Length(max: 512)]
private string $urlOverride;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $notes = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $updatedAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function preUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getCoffeeBean(): CoffeeBean
{
return $this->coffeeBean;
}
public function setCoffeeBean(CoffeeBean $coffeeBean): self
{
$this->coffeeBean = $coffeeBean;
return $this;
}
public function getMarket(): Market
{
return $this->market;
}
public function setMarket(Market $market): self
{
$this->market = $market;
return $this;
}
public function getUrlOverride(): string
{
return $this->urlOverride;
}
public function setUrlOverride(string $urlOverride): self
{
$this->urlOverride = $urlOverride;
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): self
{
$this->notes = $notes;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function __toString(): string
{
return sprintf(
'%s - %s',
$this->coffeeBean->getName() ?? 'Unknown Bean',
$this->market->getName() ?? 'Unknown Market'
);
}
}
Step 5: Create Repository Classes¶
File: src/Repository/MarketRepository.php
<?php
namespace App\Repository;
use App\Entity\Country;
use App\Entity\Market;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class MarketRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Market::class);
}
/**
* Find all active markets serving a specific country.
*
* @return Market[]
*/
public function findByCountry(Country $country): array
{
return $this->createQueryBuilder('m')
->join('m.countries', 'c')
->where('c.id = :countryId')
->andWhere('m.isActive = true')
->setParameter('countryId', $country->getId())
->getQuery()
->getResult();
}
}
File: src/Repository/AffiliateProgramRepository.php
<?php
namespace App\Repository;
use App\Entity\AffiliateProgram;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class AffiliateProgramRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AffiliateProgram::class);
}
}
File: src/Repository/BuyingOptionRepository.php
<?php
namespace App\Repository;
use App\Entity\BuyingOption;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class BuyingOptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BuyingOption::class);
}
}
Step 6: Generate Database Migration¶
Run Doctrine migration diff to create migration file:
Verify the generated migration includes:
- New tables: market, affiliate_program, buying_option, market_country
- All indexes defined in entity annotations
- Unique constraint on buying_option (coffee_bean_id, market_id)
Step 7: Create Entity Validation Tests¶
File: tests/Entity/MarketTest.php
Test validation: - Market requires at least one country - Name is required and max 255 chars - isActive defaults to true - Relationships work correctly
File: tests/Entity/AffiliateProgramTest.php
Test validation: - CUSTOM provider requires urlPattern - Reserved parameters (url, affiliateId) are rejected - Parameters must be valid JSON - urlPattern max length 2000 chars
File: tests/Entity/BuyingOptionTest.php
Test validation: - urlOverride is required - urlOverride must be valid URL - coffeeBean and market are required - Unique constraint prevents duplicates
Testing Strategy¶
Unit Tests¶
- Entity Validation Tests:
- Test all validation constraints
- Test lifecycle callbacks (timestamps)
- Test relationship management (add/remove)
-
Test __toString() methods
-
Repository Tests:
- Test
MarketRepository::findByCountry() - Test with active/inactive markets
-
Test with multiple markets serving same country
-
Enum Tests:
- Test AffiliateProvider enum values
- Test getLabel() method
Integration Tests¶
- Database Schema Tests:
- Verify all tables created
- Verify all indexes exist
- Verify unique constraints work
-
Test cascading deletes (Market → BuyingOption)
-
Constraint Tests:
- Test unique constraint on BuyingOption (bean + market)
- Test foreign key constraints
- Test NOT NULL constraints
🎯 Success Criteria¶
- All three entities (Market, AffiliateProgram, BuyingOption) created with complete validation
- Database migration generates correct schema (tables, indexes, constraints)
- Entity validation prevents invalid data:
- Market requires at least 1 country
- AffiliateProgram validates reserved parameters
- CUSTOM provider requires urlPattern
- BuyingOption enforces unique bean-market combinations
- Repository methods work correctly (MarketRepository::findByCountry)
- All unit tests pass
- Migration runs successfully up and down (reversible)
- Doctrine schema validation passes:
bin/console doctrine:schema:validate
Related Files¶
Files to Create:¶
src/Entity/Market.phpsrc/Entity/AffiliateProgram.phpsrc/Entity/BuyingOption.phpsrc/Enum/AffiliateProvider.phpsrc/Repository/MarketRepository.phpsrc/Repository/AffiliateProgramRepository.phpsrc/Repository/BuyingOptionRepository.phpmigrations/VersionXXX_multi_market_entities.php(auto-generated)tests/Entity/MarketTest.phptests/Entity/AffiliateProgramTest.phptests/Entity/BuyingOptionTest.phptests/Repository/MarketRepositoryTest.php
Files to Reference (not modify):¶
src/Entity/Country.phpsrc/Entity/CoffeeBean.phpsrc/Entity/RoasterCrawlConfig.php
Next Steps¶
After completing this phase: - Proceed to Phase 2 (Migration) to add fields to existing entities and migrate data - Entities will be ready for service layer implementation in Phase 3