Skip to content

Products by Market

ProductByMarket represents how a ProductTemplate is sold in a specific market. It connects the base product to a market, adds localized content, and configures flight search parameters.

  • Assign products to markets (ES, DE, FR)
  • Store market-specific locale (es_ES, ca_ES for Spain)
  • Configure departure airports and flight routes
  • Define search date ranges for flight caching
  • Hold translated content via ProductByMarketTranslation

Table: products_by_market

Field Type Purpose
product_template_id FK Reference to ProductTemplate
market_id FK Reference to Market
locale varchar Product locale (e.g., es_ES)
sku varchar Market SKU: ES-138-10-ES1
status varchar draft, active, inactive
trip_duration_days int Override template duration (API falls back to ProductTemplate.duration when null)
sort_order int Display ordering (default: 0)
show_itinerary_module boolean Customers see the day-by-day itinerary module instead of the current itinerary (default: false). See Show the new day-by-day itinerary
search_start_date date Flight search period start
search_end_date date Flight search period end
excluded_dates jsonb Dates to skip in searches
celebrity_page_id FK (nullable) Optional link to CmsCelebrityPage – replaces gallery with celebrity section
business_search_cabin_mode varchar (nullable) Cabins the checkout business search asks Aerticket for (App\Enums\BusinessSearchCabinMode). NULL = default (business + premium economy). Edited in the Flight Search Filters section, last on the product edit page; duplication copies it from the source product. Only set business_only on cotizaciones (hand-tuned duplicated products); catalogue products must stay on Default – business-only returns zero fares on routes whose feeder segment has no business cabin. See Business on mixed-cabin routes
economy_search_cabin_mode varchar (nullable) Cabins the checkout economy search asks Aerticket for (App\Enums\EconomySearchCabinMode). NULL = default (economy only). Edited in the same Flight Search Filters section; duplication copies it from the source product. Only set with_premium_economy on cotizaciones – it surfaces fares that fly out in economy and back in premium economy, at the cost of some all-economy options, and while it is active the economy search is read-only: no cache refresh, no rebinding, no offer repricing, so the offer keeps the generator’s price and Recalculate offer / Recalculate selected do nothing for it. The checkout session prices itself from the live search instead (as a 3+ pax session does), so the client still commits to the live price. See Economy with premium economy

Unique Constraint: product_template_id + market_id + locale

Source: backend/app/Models/ProductByMarket.php

<MARKET>-<TEMPLATE_ID>-<DAYS>-<LANG><VERSION>

Examples:

  • ES-138-10-ES1 - Spain, template 138, 10 days, Spanish, version 1
  • ES-138-10-CA1 - Spain, template 138, 10 days, Catalan, version 1
  • DE-138-10-DE1 - Germany, template 138, 10 days, German, version 1

Source: backend/app/Models/ProductByMarket.php:268-360

Translated content is stored in ProductByMarketTranslation:

Field Purpose
title, subtitle Localized product name
short_description, long_description Marketing copy
itinerary Stop content including per-day days[] (day_label, title, details): a copy of the tour’s Day by Day on es_ES products, a translation of it on other locales (see Day by Day Step)
itinerary_items Editorial per-day highlights (“Day Highlights & Activities”), copied or translated from product_templates.itinerary_items like itinerary: [{day, poi_id, items: [{title, time_slot, activity_id, description?, image?}]}], only days with items, keyed by the 1-based global tour day. poi_id snapshots the day location to detect drift; activity_id may only point to an activity included that day; description and image (storage path) are stored only when filled. Plain json (not jsonb) so key order stays stable and unchanged saves are not dirty. See backend/app/Support/ItineraryItems.php
arrival_day1_description, arrival_day2_description, departure_day1_description, departure_day2_description Text of the first / second overnight flight day (outbound / return), copied or translated from the tour. Only shown when the leg has that many flight_days; blank falls back to the default copy for the product’s language. See Flight-day texts
itinerary_source_fingerprint sha1 of the tour’s Spanish Day by Day the translation was made from; set by DayByDayTranslator, used to flag outdated translations
included_in_price Per-locale overrides for the six “What’s included” categories
not_included Per-locale override for the free-text “What’s not included” block
url_slug SEO-friendly URL path
meta_title, meta_description SEO metadata

The legacy accommodation_details and meal_plans columns still exist on the translation table for rollback compatibility, but the admin UI no longer uses them for customer-facing inclusions.

When publishing to a market or editing translations, the itinerary is merged from the ProductTemplate structure and the translated content using ItinerarySchema::buildTranslationDays(). The merge follows these rules:

  • Text (day_label, title, details) comes from the translation, falling back to the template
  • Images (day_image) are never stored in translations – they come from the ProductTemplate only
  • Activities (activities[]) come from the SupplierTour itinerary day assignments, not from translations
  • The API resource (ProductByMarketResource) merges both day_image and activities into the response at request time

Images and activities are only merged on detail endpoints (by ID, slug, SKU, preview) – the listing endpoint skips them to avoid N+1 queries. The same applies to itinerary_items, merged per day as items (see Itinerary Items). The itinerary merge and ProductByMarketItineraryReconciler never touch itinerary_items; only the copy or translation made when the product is created, Publish itinerary (es_ES) and the itinerary section’s “Translate from Tour” (other locales) replace them.

This merge happens in 4 locations: PublishToMarketAction, EditProductByMarket, CreateProductByMarket, and ProductByMarketForm.

AI Translation: The “Translate from Tour” actions auto-translate the fields from the tour’s content, from the template’s source_locale (ProductByMarketTranslationService). The itinerary section’s action uses DayByDayTranslator instead (see Other-Language Translation).

Customer-facing inclusions are resolved from two layers:

  1. product_templates.included_in_price / product_templates.not_included provide the source-locale defaults.
  2. product_by_market_translations.included_in_price / product_by_market_translations.not_included provide optional per-locale overrides.

ProductByMarket::getIncludedInPrice() merges the six inclusion categories key-by-key, with the translation winning only for the keys it actually fills. ProductByMarket::getNotIncluded() applies the same fallback rule to the free-text “Not included” block.

This lets an operator override, for example, only gastronomy in Spanish while the other five categories still fall back to the template text.

Source: backend/app/Services/ProductByMarketTranslationService.php, backend/app/Support/ItinerarySchema.php

Supplier Entity Translations in Detail View

Section titled “Supplier Entity Translations in Detail View”

On detail endpoints (by ID, slug, SKU, preview), hotel and activity content from supplier entities is also returned in the market locale. ProductByMarketResource calls $hotel->translated('description', $locale) and $activity->translated('name', $locale) with fallback to the source language. Translations are eager-loaded to prevent N+1 queries.

See Supplier Entity Translations for the full translation architecture.

Each ProductByMarket can have multiple departure airports, each with its own route configuration.

ProductByMarket
├── search_start_date, search_end_date (shared)
├── excluded_dates (shared)
├── domestic_result_filters (json, null = Global; every departure airport)
│
└── ProductByMarketFlightConfig (per departure airport)
├── airport_id (MAD, BCN, etc.)
├── type (multi_city or separate)
├── is_active
├── result_filters (json, international only, null = Global)
├── result_filters_fallback_dates (json, written by the generator)
│
└── ProductByMarketFlightLeg[] (route segments)
├── leg_index
├── origin_airport_id
├── destination_airport_id
├── origin_city
├── destination_city
├── day_offset
└── flight_type (international, domestic, arnk)
Type Description API Calls
multi_city Single booking for all legs 1 per date
separate Individual bookings per leg N per date
Type Description
international Cross-border flight (requires API search)
domestic Same-country flight (requires API search)
arnk Ground transport (no flight search needed)

Source: backend/app/Models/ProductByMarketFlightConfig.php, backend/app/Models/ProductByMarketFlightLeg.php

When creating an offer, the wizard uses flight config legs to determine required selections:

  1. International legs are combined into a single multi-city search
  2. Domestic legs are searched separately (each creates an OfferFlight record)
  3. ARNK legs are skipped (no flight selection needed)

The leg_index from ProductByMarketFlightLeg carries through to OfferFlight, maintaining the trip sequence.

See Offers for the selection workflow and OfferFlight model.

The global Flight Search Filters decide what Aerticket is asked for, once per route for every product. A product can narrow which of the returned flights it uses: its international flights per departure airport, its domestic flights once for every airport (domestic legs are the same whichever airport the trip leaves from). The Aerticket request never changes, so these filters cost no extra searches and do not change how the cache is filled.

The international block (App\Enums\FlightResultScope::International) is stored per airport in ProductByMarketFlightConfig.result_filters ({"international": {...}}), the domestic block once on the product in ProductByMarket.domestic_result_filters. ProductByMarketFlightConfig::storedResultFilters() returns the right one for a scope, so every consumer reads both through the airport’s config. An empty field, or a null column, follows Global:

Field International Domestic Rule
max_stops yes yes Capped at the global max stops (0 when Global is direct-only)
excluded_airlines yes yes Added to the global exclusions, which always apply. Matched on the operating carrier: a flight sold by an excluded airline but flown by another passes
latest_arrival_time yes no Replaces the global latest arrival time for this product’s international flights. Not capped: a time later than Global widens the cutoff. Domestic legs keep the global cutoff and their per-leg departure window
sort_order, tie_break, hour_value_percent yes yes The scope’s flight ranking: which flight is shown and bound first. Only reorders, never removes a flight. hour_value_percent is used only by Best value
max_layover_hours yes yes Only shorter than the global max layover (a longer value is capped). Checked on the itineraries the customer is shown, and drops flights with no fallback

Ranking fields go through FlightRanking::normalizeStored() when saved (via FlightResultFilters::normalizeForStorage()), so only valid values are kept. Ranking fields alone do not make the result filters customised (FlightResultFilters::isCustomized()), and the fewer-than-3 fallback below depends only on max stops.

Fallback. When the product’s own max stops leaves fewer than 3 flights for a departure date (FlightResultFilters::MINIMUM_RESULTS), the list is completed with the best flights that pass the global max stops, after the product’s own matches. Excluded airlines are never brought back: if nothing remains, the date has no flights. For a domestic leg the same idea applies per itinerary: when no itinerary meets the product’s max stops, one within the global max stops is bound instead.

Where they apply: the auto offer generator (international fare and domestic legs, including domestic rebinds), the checkout economy and business live searches, and the Diagnose Missing Offers gate passes_product_result_filters (the ranking fields: gate passes_max_layover and the order of the surviving rows). The cache populator and the admin Search Flights page never use a product’s ranking. Existing offers are not recomputed when the filters are saved; new offers, rebinds and checkout use them from then on. A different ranking can bind a different flight, and so a different arrival date and land price, the next time one of those runs.

Fallback dates. result_filters_fallback_dates lists the future departure dates where the fallback was needed (fewer than 3 own matches) and the generator still created an offer that day. Each pass rewrites the dates it ranked, past dates drop out, and saving changed filters clears the list. Arkana shows it as a warning on the airport’s tab.

Edited in the Flight results per departure airport section of the edit page (one tab per departure airport for international flights, one block below them for domestic flights); see Arkana → Products by Market.

Source: backend/app/Services/Flights/Domain/FlightResultFilters.php, backend/app/Services/Flights/Domain/FlightResultSelection.php, backend/app/Services/Flights/Domain/FlightRanking.php, backend/app/Models/ProductByMarketFlightConfig.php (resultFilters(), ranking()), backend/app/Models/Offer.php (flightResultFilters(), flightRanking())

Flight searches are configured with:

  • search_start_date - First departure date to search
  • search_end_date - Last departure date to search
  • excluded_dates - Specific dates to skip (holidays, blackouts)
$product->shouldSearchDate($date); // Combined check
$product->isDateInSearchRange($date); // Range check only
$product->isDateExcluded($date); // Exclusion check only
$product->getSearchableDaysCount(); // Total searchable days

Source: backend/app/Models/ProductByMarket.php:219-265

// Per flight config
$config->getApiCallsPerDate(); // 1 for multi_city, N for separate
$config->getTotalEstimatedApiCalls(); // Days * calls per date
// Per product by market (all configs)
$product->getTotalEstimatedApiCalls(); // Sum across all configs

ProductByMarket supports generating preview URLs for viewing draft/inactive products on the frontend.

// Generate signed API URL (expires in 60 minutes by default)
$apiUrl = $product->generatePreviewUrl(60);
// Generate frontend URL with embedded preview token
$frontendUrl = $product->getFrontendPreviewUrl(60);
// Returns: https://frontend.com/es/circuito/slug?preview=<base64_signed_url>

The preview URL uses Laravel’s signed URL feature (HMAC-SHA256) to authenticate access without requiring user login.

Source: backend/app/Models/ProductByMarket.php:455-519

A SupplierTour can also be previewed on the frontend before it is published to any market – useful for content editors iterating on a draft tour, or on Day by Day changes not published yet. This is distinct from the ProductByMarket preview above: there is no market and no translation – the tour renders directly from its saved ProductTemplate, in Spanish (es_ES) like its Day by Day.

A “Preview on Frontend” button is available in the SupplierTour edit and view page headers and in the Day by Day step. Clicking it opens the frontend preview in a new tab using a signed URL that expires in 60 minutes.

Source: backend/app/Filament/Resources/Suppliers/SupplierTours/Actions/SupplierTourPreviewAction.php

GET /api/supplier-tours/{id}/preview

Protected by Laravel’s signed:relative middleware. Returns a Product-shaped JSON payload (same shape the frontend ProductPage component consumes for published products) or 404 when the tour has no linked ProductTemplate.

// Generate signed API URL (relative, host-agnostic)
$apiUrl = $tour->generatePreviewUrl(60);
// Generate frontend URL with embedded preview token
$frontendUrl = $tour->getFrontendPreviewUrl(60);
// Returns: {FRONTEND_URL}/preview/tour/{id}?preview=<base64-signed-url>

The signed URL is generated as relative (absolute: false) so validation succeeds regardless of which host the SSR reaches. In production the admin (arkana.byvolare.com) and the API (api.byvolare.com) are on different hostnames, and an absolute-signed URL would fail validation when called from the other host. The frontend URL embeds the relative signed path as a URL-safe base64 token; the Astro preview page decodes it and calls the API.

Source: backend/app/Models/SupplierTour.php:251-278, backend/app/Http/Controllers/Api/SupplierTourController.php, backend/routes/api.php:242-244

/preview/tour/{id}?preview=<base64-signed-url>

Renders the same ProductPage component used for published products, so the preview matches production layout. Redirects to /es/404 on invalid token / missing tour and /es/500 on other errors.

Source: frontend/src/pages/preview/tour/[id].astro

SupplierTourPreviewPayloadBuilder synthesizes the Product payload by:

  • Loading the tour’s ProductTemplate and using es_ES for all content
  • Setting show_itinerary_module: true and the Spanish flight_day_copy, so the preview always shows the day-by-day itinerary module
  • Resolving stored image paths to full URLs (itinerary, hero, gallery, activities, hotels)
  • Merging per-day activities (included, then extra and substitution) and the template’s itinerary_items as items into each itinerary day (matching ProductByMarketResource; items share the BuildsItineraryItems trait)
  • Grouping hotels by tier (selection / luxury / grand_luxury) with night ranges (a label like Noches 1 a 3) and lowest-price-from EUR pricing
  • Emitting the same accommodation shape as the public resource, including both the legacy image (first image) and images[] (all images in admin-configured order) for parity with ProductByMarketResource::buildTierHotels()
  • Stubbing market-only fields (url_slug, countries, country_name, celebrity, departure_airports) as null / empty – the frontend tolerates these for the preview route
  • Using a synthetic SKU PREVIEW-ST-{id} and status: 'preview'

Source: backend/app/Services/SupplierTourPreviewPayloadBuilder.php

destination_country_code (nullable ISO-3166-1 alpha-2) is the trip’s operational destination, set per product from a country select in Arkana. Insurance reads it through a single resolver:

$pbm->getDestinationCountryIsoCode(); // "CR"
destination = products_by_market.destination_country_code
?? first linked CMS country with a non-empty iso_code
?? null

The explicit column wins; the CMS association is a fallback for catalog products that predate it, and CMS countries whose iso_code was never filled are skipped rather than returned empty. Bespoke quotes are never linked to a CMS country, so the column is their only source.

Two rules follow from it: a product whose destination does not resolve cannot be set to active (enforced in the edit form and in the bulk status action), and duplication carries the value into the clone through replicate(), so quote-of-a-quote chains stay correct.

For multi-country trips this column is also how the declared destination becomes deliberate: the CMS association orders by pivot sort_order alone, so two countries tied at the same value resolve in whatever order the database returns.

Source: backend/app/Models/ProductByMarket.php (getDestinationCountryIsoCode), Insurance

Three helper methods resolve the primary destination’s localized name and ISO code from the linked cmsCountries. They feed the GA4 funnel taxonomy — they are the marketing country, not the operational destination above.

$pbm->getPrimaryCountryName(); // localized (PBM locale), e.g. "Costa Rica"
$pbm->getPrimaryRegionName(); // localized via cmsCountries.first().region
$pbm->getPrimaryCountryIsoCode(); // ISO-3166-1 alpha-2 (e.g. "CR")

All three resolve from the first linked CmsCountry (collection order). They return null when no country is linked, no matching translation exists, or iso_code is unset. Eager-load cmsCountries.translations and cmsCountries.region.translations to avoid N+1 queries.

The checkout options resources expose country_name and region_name from these helpers, but their country_code comes from getDestinationCountryIsoCode(): the field feeds the insurance quote, so it has to describe the real destination.

Source: backend/app/Models/ProductByMarket.php (getPrimaryCountryName, getPrimaryRegionName, getPrimaryCountryIsoCode)

ProductByMarket can optionally link to a CmsCelebrityPage via celebrity_page_id (nullable FK, nullOnDelete). When linked, the product detail API returns a celebrity key containing eyebrow, title, description, poster, CTA, and trailers from the celebrity page. The frontend renders the CelebritySection component instead of the image gallery.

  • In Filament admin, a “Celebrity Page” section shows a market-filtered Select dropdown (only active celebrity pages for the product’s market)
  • Changing the market clears any stale celebrity page selection
  • If the linked celebrity page is incomplete (missing title or poster image), the API returns null for the celebrity key and the gallery displays normally

Source: backend/app/Filament/Resources/ProductsByMarket/Schemas/ProductByMarketForm.php:783, backend/app/Http/Resources/ProductByMarketResource.php

  • One ProductByMarket per template + market + locale combination
  • Templates lock structural edits when at least one linked market product is active; while every linked product is draft/inactive, admins can still edit the tour (see Tour Lock Behavior)
  • Flight configs are generated based on itinerary when product is created
  • Search dates are shared across all departure airports
  • ARNK legs do not trigger flight searches
  • The linked Market’s default_margin is inherited by new offers created for this product-market combination (see Offers: Margin Resolution)