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.
Purpose
Section titled “Purpose”- 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
Data Model
Section titled “Data Model”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
SKU Format
Section titled “SKU Format”<MARKET>-<TEMPLATE_ID>-<DAYS>-<LANG><VERSION>Examples:
ES-138-10-ES1- Spain, template 138, 10 days, Spanish, version 1ES-138-10-CA1- Spain, template 138, 10 days, Catalan, version 1DE-138-10-DE1- Germany, template 138, 10 days, German, version 1
Source: backend/app/Models/ProductByMarket.php:268-360
Translations
Section titled “Translations”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.
Itinerary Translation Merge
Section titled “Itinerary Translation Merge”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 bothday_imageandactivitiesinto 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).
Included / Not Included Merge
Section titled “Included / Not Included Merge”Customer-facing inclusions are resolved from two layers:
product_templates.included_in_price/product_templates.not_includedprovide the source-locale defaults.product_by_market_translations.included_in_price/product_by_market_translations.not_includedprovide 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.
Flight Configuration
Section titled “Flight Configuration”Each ProductByMarket can have multiple departure airports, each with its own route configuration.
Flight Config Hierarchy
Section titled “Flight Config Hierarchy”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)Flight Types
Section titled “Flight Types”| Type | Description | API Calls |
|---|---|---|
| multi_city | Single booking for all legs | 1 per date |
| separate | Individual bookings per leg | N per date |
Leg Types
Section titled “Leg Types”| 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
Flight Config to Offer Mapping
Section titled “Flight Config to Offer Mapping”When creating an offer, the wizard uses flight config legs to determine required selections:
- International legs are combined into a single multi-city search
- Domestic legs are searched separately (each creates an OfferFlight record)
- 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.
Result Filters per Departure Airport
Section titled “Result Filters per Departure Airport”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())
Search Date Configuration
Section titled “Search Date Configuration”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 daysSource: backend/app/Models/ProductByMarket.php:219-265
API Calls Estimation
Section titled “API Calls Estimation”// 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 configsPreview URLs
Section titled “Preview URLs”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
Supplier Tour Preview
Section titled “Supplier Tour Preview”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.
Admin Action
Section titled “Admin Action”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
API Endpoint
Section titled “API Endpoint”GET /api/supplier-tours/{id}/previewProtected 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
Frontend Route
Section titled “Frontend Route”/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
Payload Builder
Section titled “Payload Builder”SupplierTourPreviewPayloadBuilder synthesizes the Product payload by:
- Loading the tour’s
ProductTemplateand usinges_ESfor all content - Setting
show_itinerary_module: trueand the Spanishflight_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_itemsasitemsinto each itinerary day (matchingProductByMarketResource; items share theBuildsItineraryItemstrait) - 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) andimages[](all images in admin-configured order) for parity withProductByMarketResource::buildTierHotels() - Stubbing market-only fields (
url_slug,countries,country_name,celebrity,departure_airports) asnull/ empty – the frontend tolerates these for the preview route - Using a synthetic SKU
PREVIEW-ST-{id}andstatus: 'preview'
Source: backend/app/Services/SupplierTourPreviewPayloadBuilder.php
Destination Country
Section titled “Destination Country”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 ?? nullThe 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
Geographic Helpers (GA4)
Section titled “Geographic Helpers (GA4)”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)
Celebrity Page Link
Section titled “Celebrity Page Link”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
nullfor thecelebritykey and the gallery displays normally
Source: backend/app/Filament/Resources/ProductsByMarket/Schemas/ProductByMarketForm.php:783, backend/app/Http/Resources/ProductByMarketResource.php
Business Rules
Section titled “Business Rules”- 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_marginis inherited by new offers created for this product-market combination (see Offers: Margin Resolution)
Related
Section titled “Related”- Product Templates - Base definitions
- Admin Panel - Filament management
- Flight Search - Search integration
- Database Relationships - Entity diagram and FK references