Multi-Market Implementation: Phase 3 - Services Layer¶
Status: Planning¶
Dependencies¶
Requires completed: - Phase 1 (Entities) - Market, AffiliateProgram, BuyingOption entities - Phase 2 (Migration) - RoasterCrawlConfig.market, CrawlUrl.available fields
Blocks: - Phase 4 (Cache/Repository) - Cache invalidation needs service methods - Phase 5 (API Integration) - API uses these services - Phase 6 (EasyAdmin) - Controllers may use services for validation
📋 Todo Checklist¶
- [ ] Create AffiliateUrlService for URL transformation
- [ ] Implement Twig template engine with sandbox security
- [ ] Create hard-coded patterns for each affiliate provider
- [ ] Create BuyingOptionService for URL resolution
- [ ] Implement priority cascade logic (override → priority → affiliate)
- [ ] Add MarketRepository::findByCountry() method
- [ ] Create comprehensive unit tests for both services
- [ ] Create integration tests for URL resolution flow
- [ ] Document service usage patterns
🔍 Analysis & Investigation¶
Problem Statement¶
The multi-market system needs two core services: 1. AffiliateUrlService - Transform product URLs into affiliate tracking URLs using Twig templates 2. BuyingOptionService - Resolve which URL to show a visitor based on their country
Architecture¶
Service responsibilities: - AffiliateUrlService: Pure transformation (URL in → affiliate URL out) - BuyingOptionService: Business logic (visitor country → best URL with affiliate) - MarketRepository: Data access (find markets by country)
Interaction flow:
Visitor Country → BuyingOptionService
↓ finds markets
↓ finds CrawlUrls
↓ applies priority cascade
↓ calls AffiliateUrlService
↓
Affiliate URL (or NULL)
Security Considerations¶
Twig templates are user input - must be sandboxed: - Restrict allowed filters (url_encode, escape only) - No functions, methods, or properties access - Strict variable mode (fail on undefined) - No tags (no control flow)
Performance Considerations¶
BuyingOptionService caching:
- Results cached with tag-aware cache
- Cache key: buying_url_{beanId}_{countryId}
- TTL: 1 week (604800 seconds)
- Cache tags for invalidation
Repository optimization: - Use joins to prevent N+1 queries - Fetch all needed relationships upfront
📝 Implementation Plan¶
Prerequisites¶
- Phase 1 and 2 completed
- Twig component installed:
composer require twig - Tag-aware cache configured (
api.cache)
Step-by-Step Implementation¶
Step 1: Create AffiliateUrlService¶
File: src/Service/Affiliate/AffiliateUrlService.php
<?php
namespace App\Service\Affiliate;
use App\Entity\AffiliateProgram;
use App\Enum\AffiliateProvider;
use Psr\Log\LoggerInterface;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\SecurityPolicy;
final readonly class AffiliateUrlService
{
public function __construct(
private LoggerInterface $logger
) {}
/**
* Transform a product URL into an affiliate tracking URL.
*
* @param string $url Original product URL
* @param AffiliateProgram $affiliateProgram Affiliate program configuration
* @return string Transformed URL with affiliate tracking
*/
public function transformUrl(string $url, AffiliateProgram $affiliateProgram): string
{
try {
// Get template pattern (custom or hard-coded)
$pattern = $this->getUrlPattern($affiliateProgram);
// Prepare template variables
$variables = $this->prepareTemplateVariables($url, $affiliateProgram);
// Render template
$twig = $this->getTwigEnvironment();
$template = $twig->createTemplate($pattern);
$transformedUrl = $template->render($variables);
$this->logger->debug('Affiliate URL transformed', [
'original_url' => $url,
'transformed_url' => $transformedUrl,
'provider' => $affiliateProgram->getProvider()->value,
'program_id' => $affiliateProgram->getId(),
]);
return $transformedUrl;
} catch (\Throwable $e) {
$this->logger->error('Affiliate URL transformation failed', [
'url' => $url,
'program' => $affiliateProgram->getName(),
'error' => $e->getMessage(),
]);
// Fallback: return original URL
return $url;
}
}
/**
* Get URL pattern for transformation.
* Uses custom pattern if set, otherwise uses hard-coded pattern.
*/
private function getUrlPattern(AffiliateProgram $affiliateProgram): string
{
// Custom override pattern
if ($affiliateProgram->getUrlPattern() !== null) {
return $affiliateProgram->getUrlPattern();
}
// Hard-coded patterns by provider
return match ($affiliateProgram->getProvider()) {
AffiliateProvider::AMAZON_ASSOCIATES => $this->getAmazonAssociatesPattern(),
AffiliateProvider::AWIN => $this->getAwinPattern(),
AffiliateProvider::IMPACT => $this->getImpactPattern(),
AffiliateProvider::PARTNERIZE => $this->getPartnerizePattern(),
AffiliateProvider::CUSTOM => throw new \LogicException(
'CUSTOM provider requires urlPattern to be set'
),
};
}
private function getAmazonAssociatesPattern(): string
{
return '{{ url }}{% if "?" in url %}&{% else %}?{% endif %}tag={{ affiliateId }}';
}
private function getAwinPattern(): string
{
// Requires parameters: awinmid
return 'https://www.awin1.com/cread.php?awinmid={{ awinmid }}&awinaffid={{ affiliateId }}&ued={{ url|url_encode }}';
}
private function getImpactPattern(): string
{
// WARNING: This pattern is a placeholder and MUST be verified
return 'https://tracking.impact.com/SID-{{ affiliateId }}/redirect?url={{ url|url_encode }}';
}
private function getPartnerizePattern(): string
{
// WARNING: This pattern is a placeholder and MUST be verified
return 'https://prf.hn/click/camref:{{ affiliateId }}/destination:{{ url|url_encode }}';
}
/**
* Prepare template variables.
*
* System variables: url, affiliateId
* User variables: from AffiliateProgram.parameters JSON
*/
private function prepareTemplateVariables(
string $url,
AffiliateProgram $affiliateProgram
): array {
$variables = [
'url' => $url,
'affiliateId' => $affiliateProgram->getAffiliateId(),
];
// Merge user-defined parameters
if ($affiliateProgram->getParameters() !== null) {
$variables = array_merge($variables, $affiliateProgram->getParameters());
}
return $variables;
}
/**
* Create sandboxed Twig environment.
*
* Security restrictions:
* - No tags (no control flow)
* - Limited filters (url_encode, escape only)
* - No functions, methods, properties
* - Strict variables (fail on undefined)
*/
private function getTwigEnvironment(): Environment
{
$loader = new ArrayLoader();
$twig = new Environment($loader, [
'cache' => false,
'autoescape' => false, // We want raw URLs, not HTML-escaped
'strict_variables' => true, // Fail on undefined variables
'optimizations' => -1,
]);
// Restrict to safe filters only
$policy = new SecurityPolicy(
tags: [],
filters: ['url_encode', 'escape'],
methods: [],
properties: [],
functions: []
);
$twig->addExtension(new SandboxExtension($policy, true));
return $twig;
}
}
Step 2: Extend MarketRepository¶
File: src/Repository/MarketRepository.php (modify existing)
Add method already implemented in Phase 1:
/**
* 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();
}
Step 3: Create BuyingOptionService¶
File: src/Service/Market/BuyingOptionService.php
<?php
namespace App\Service\Market;
use App\Entity\CoffeeBean;
use App\Entity\Country;
use App\Repository\BuyingOptionRepository;
use App\Repository\MarketRepository;
use App\Service\Affiliate\AffiliateUrlService;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final readonly class BuyingOptionService
{
private const CACHE_TTL = 604800; // 1 week
public function __construct(
private BuyingOptionRepository $buyingOptionRepository,
private MarketRepository $marketRepository,
private AffiliateUrlService $affiliateUrlService,
private CacheInterface $apiCache,
private LoggerInterface $logger
) {}
/**
* Get the buying URL for a coffee bean based on visitor's country.
*
* Priority cascade:
* 1. Manual override (BuyingOption.urlOverride)
* 2. RoasterCrawlConfig priority (highest priority wins)
* 3. Affiliate transformation (apply market's affiliate program)
* 4. NULL (bean not available for this country)
*
* @param CoffeeBean $bean Coffee bean entity (must have crawlUrls loaded with joins)
* @param Country $visitorCountry Visitor's country
* @return string|null Affiliate URL or NULL if not available
*/
public function getUrlForVisitor(CoffeeBean $bean, Country $visitorCountry): ?string
{
$cacheKey = sprintf('buying_url_%s_%s', $bean->getId(), $visitorCountry->getId());
return $this->apiCache->get(
$cacheKey,
function (ItemInterface $item) use ($bean, $visitorCountry) {
$item->expiresAfter(self::CACHE_TTL);
// Tag for cache invalidation
$item->tag([
'buying_urls',
'coffee_bean_' . $bean->getId(),
'country_' . $visitorCountry->getId(),
]);
return $this->resolveUrl($bean, $visitorCountry, $item);
}
);
}
/**
* Check if a coffee bean is available in any market.
*
* @param CoffeeBean $bean Coffee bean entity
* @return bool True if bean has any available CrawlUrl
*/
public function isAvailableInAnyMarket(CoffeeBean $bean): bool
{
foreach ($bean->getCrawlUrls() as $crawlUrl) {
if ($crawlUrl->isAvailable()) {
return true;
}
}
return false;
}
/**
* Internal URL resolution logic.
*/
private function resolveUrl(
CoffeeBean $bean,
Country $visitorCountry,
ItemInterface $cacheItem
): ?string {
// 1. Find markets serving this country
$markets = $this->marketRepository->findByCountry($visitorCountry);
if (empty($markets)) {
$this->logger->debug('No markets found for country', [
'bean_id' => $bean->getId(),
'country_id' => $visitorCountry->getId(),
]);
return null;
}
// Tag cache with market IDs
foreach ($markets as $market) {
$cacheItem->tag('market_' . $market->getId());
}
// 2. Check for manual BuyingOption override (highest priority)
foreach ($markets as $market) {
$buyingOption = $this->buyingOptionRepository->findOneBy([
'coffeeBean' => $bean,
'market' => $market,
]);
if ($buyingOption) {
$this->logger->debug('Manual buying option found', [
'bean_id' => $bean->getId(),
'market_id' => $market->getId(),
'url' => $buyingOption->getUrlOverride(),
]);
// Tag cache with buying option
$cacheItem->tag(sprintf(
'buying_option_%s_%s',
$bean->getId(),
$market->getId()
));
return $buyingOption->getUrlOverride();
}
}
// 3. Find matching CrawlUrls from configs
$matchingOptions = $this->findMatchingCrawlUrls($bean, $visitorCountry, $markets);
if (empty($matchingOptions)) {
$this->logger->debug('No matching crawl URLs found', [
'bean_id' => $bean->getId(),
'country_id' => $visitorCountry->getId(),
]);
return null;
}
// 4. Sort by priority (highest first) and pick winner
usort($matchingOptions, fn($a, $b) => $b['priority'] <=> $a['priority']);
$winner = $matchingOptions[0];
$this->logger->debug('Selected crawl URL by priority', [
'bean_id' => $bean->getId(),
'config_id' => $winner['config']->getId(),
'priority' => $winner['priority'],
'url' => $winner['crawlUrl']->getUrl(),
]);
// 5. Apply affiliate transformation if available
if ($winner['market'] !== null) {
$affiliateProgram = $winner['market']->getAffiliateProgram();
if ($affiliateProgram && $affiliateProgram->isActive()) {
$cacheItem->tag('affiliate_program_' . $affiliateProgram->getId());
return $this->affiliateUrlService->transformUrl(
$winner['crawlUrl']->getUrl(),
$affiliateProgram
);
}
}
// 6. Fallback to direct URL (no affiliate or legacy mode)
return $winner['crawlUrl']->getUrl();
}
/**
* Find all CrawlUrls that match visitor's country.
*
* Checks:
* 1. CrawlUrl is available
* 2. RoasterCrawlConfig is active
* 3. Roaster ships to visitor's country (logistics check)
* 4. Market serves visitor's country (monetization check, if market exists)
*/
private function findMatchingCrawlUrls(
CoffeeBean $bean,
Country $visitorCountry,
array $markets
): array {
$marketIds = array_map(fn($m) => $m->getId(), $markets);
$matchingOptions = [];
foreach ($bean->getCrawlUrls() as $crawlUrl) {
if (!$crawlUrl->isAvailable()) {
continue; // Skip unavailable URLs
}
$config = $crawlUrl->getRoasterCrawlConfig();
if (!$config->isActive()) {
continue;
}
// Check 1: Can roaster ship to this country? (logistics)
if (!$this->configShipsToCountry($config, $visitorCountry->getId())) {
continue;
}
$configMarket = $config->getMarket();
if ($configMarket !== null) {
// Check 2: Does market serve this country? (monetization)
if (!in_array($configMarket->getId(), $marketIds, true)) {
continue; // Market doesn't serve this country
}
// Both checks passed
$matchingOptions[] = [
'crawlUrl' => $crawlUrl,
'config' => $config,
'market' => $configMarket,
'priority' => $config->getPriority(),
];
} else {
// Legacy mode: no market = no affiliate transformation
$matchingOptions[] = [
'crawlUrl' => $crawlUrl,
'config' => $config,
'market' => null,
'priority' => $config->getPriority(),
];
}
}
return $matchingOptions;
}
/**
* Check if a RoasterCrawlConfig ships to a specific country.
*/
private function configShipsToCountry($config, string $countryId): bool
{
// No restrictions = ships everywhere
if ($config->getShipsTo()->isEmpty()) {
return true;
}
// Check if country is in shipsTo collection
foreach ($config->getShipsTo() as $country) {
if ($country->getId() === $countryId) {
return true;
}
}
return false;
}
}
Step 4: Create Service Tests¶
File: tests/Service/Affiliate/AffiliateUrlServiceTest.php
<?php
namespace App\Tests\Service\Affiliate;
use App\Entity\AffiliateProgram;
use App\Enum\AffiliateProvider;
use App\Service\Affiliate\AffiliateUrlService;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class AffiliateUrlServiceTest extends TestCase
{
private AffiliateUrlService $service;
protected function setUp(): void
{
$this->service = new AffiliateUrlService(new NullLogger());
}
public function testAmazonAssociatesTransformation(): void
{
$program = new AffiliateProgram();
$program->setProvider(AffiliateProvider::AMAZON_ASSOCIATES);
$program->setAffiliateId('beanb-20');
$url = 'https://example.com/product/123';
$result = $this->service->transformUrl($url, $program);
$this->assertEquals('https://example.com/product/123?tag=beanb-20', $result);
}
public function testAmazonAssociatesWithExistingQueryParams(): void
{
$program = new AffiliateProgram();
$program->setProvider(AffiliateProvider::AMAZON_ASSOCIATES);
$program->setAffiliateId('beanb-20');
$url = 'https://example.com/product/123?color=blue';
$result = $this->service->transformUrl($url, $program);
$this->assertEquals('https://example.com/product/123?color=blue&tag=beanb-20', $result);
}
public function testAwinTransformation(): void
{
$program = new AffiliateProgram();
$program->setProvider(AffiliateProvider::AWIN);
$program->setAffiliateId('67890');
$program->setParameters(['awinmid' => '12345']);
$url = 'https://example.com/product/123';
$result = $this->service->transformUrl($url, $program);
$expected = 'https://www.awin1.com/cread.php?awinmid=12345&awinaffid=67890&ued=https%3A%2F%2Fexample.com%2Fproduct%2F123';
$this->assertEquals($expected, $result);
}
public function testCustomPatternTransformation(): void
{
$program = new AffiliateProgram();
$program->setProvider(AffiliateProvider::CUSTOM);
$program->setAffiliateId('partner-id');
$program->setUrlPattern('{{ url }}{% if "?" in url %}&{% else %}?{% endif %}ref={{ affiliateId }}&utm_source={{ source }}');
$program->setParameters(['source' => 'bean-business']);
$url = 'https://example.com/product/123';
$result = $this->service->transformUrl($url, $program);
$this->assertEquals('https://example.com/product/123?ref=partner-id&utm_source=bean-business', $result);
}
public function testFallbackToOriginalUrlOnError(): void
{
$program = new AffiliateProgram();
$program->setProvider(AffiliateProvider::CUSTOM);
$program->setAffiliateId('partner-id');
// Invalid template (missing variable)
$program->setUrlPattern('{{ url }}?ref={{ missing_variable }}');
$url = 'https://example.com/product/123';
$result = $this->service->transformUrl($url, $program);
// Should fallback to original URL
$this->assertEquals($url, $result);
}
public function testUrlPatternOverridesHardCodedPattern(): void
{
$program = new AffiliateProgram();
$program->setProvider(AffiliateProvider::AMAZON_ASSOCIATES);
$program->setAffiliateId('beanb-20');
// Override hard-coded pattern
$program->setUrlPattern('{{ url }}?tag={{ affiliateId }}&ref=custom');
$url = 'https://example.com/product/123';
$result = $this->service->transformUrl($url, $program);
$this->assertEquals('https://example.com/product/123?tag=beanb-20&ref=custom', $result);
}
}
File: tests/Service/Market/BuyingOptionServiceTest.php
Test priority cascade, manual overrides, availability checks, caching behavior.
Step 5: Create Integration Tests¶
File: tests/Integration/MultiMarketUrlResolutionTest.php
Test full flow: Visitor country → URL resolution → Affiliate transformation
Testing Strategy¶
Unit Tests¶
- AffiliateUrlService:
- Test each provider's hard-coded pattern
- Test custom pattern rendering
- Test parameter merging
- Test error handling (fallback to original URL)
-
Test URL pattern override
-
BuyingOptionService:
- Test manual override priority
- Test RCC priority sorting
- Test legacy mode (market = NULL)
- Test availability filtering
- Test shipping restrictions
- Test market country filtering
-
Test cache tagging
-
MarketRepository:
- Test findByCountry() with active/inactive markets
- Test with multiple markets serving same country
Integration Tests¶
- Full URL Resolution Flow:
- Create test data (markets, configs, beans, URLs)
- Test visitor from different countries
- Verify correct URL selected
- Verify affiliate transformation applied
-
Test priority cascade
-
Cache Invalidation:
- Test cache tags are set correctly
- Verify cache invalidates when entities change
🎯 Success Criteria¶
- AffiliateUrlService transforms URLs correctly for all providers
- Twig sandbox prevents code injection
- BuyingOptionService implements correct priority cascade:
- Manual override (BuyingOption)
- RCC priority (highest wins)
- Affiliate transformation
- NULL if not available
- Cache works correctly with proper tags
- Legacy mode (market = NULL) works
- All unit tests pass
- Integration tests verify full flow
- Performance acceptable (caching reduces database queries)
- Error handling graceful (fallback to original URL)
Related Files¶
Files to Create:¶
src/Service/Affiliate/AffiliateUrlService.phpsrc/Service/Market/BuyingOptionService.phptests/Service/Affiliate/AffiliateUrlServiceTest.phptests/Service/Market/BuyingOptionServiceTest.phptests/Integration/MultiMarketUrlResolutionTest.php
Files to Modify:¶
src/Repository/MarketRepository.php(add findByCountry if not already present)
Files Referenced:¶
src/Entity/Market.phpsrc/Entity/AffiliateProgram.phpsrc/Entity/BuyingOption.phpsrc/Entity/RoasterCrawlConfig.phpsrc/Entity/CrawlUrl.phpsrc/Entity/Country.php
Next Steps¶
After completing this phase: - Proceed to Phase 4 (Cache/Repository) to extend cache invalidation and repository methods - Services ready for API integration in Phase 5 - Services available for EasyAdmin validation in Phase 6