Skip to content

Dynamic Flight Cache

Pre-fetches and stores flight pricing data for travel products to enable fast price lookups without real-time API calls. Supports hybrid configurations combining international multi-city flights with domestic flights.

  • Displaying flight prices on product listing pages
  • Calculating total trip costs for offers
  • Price trend analysis and monitoring
  • Batch pricing for seasonal campaigns

The system uses PostgreSQL as the cache storage backend (not Redis or file cache). Flight pricing data is stored in normalized tables with relationships between routes, cache entries, and flight segments.

ProductByMarket
└── ProductByMarketFlightConfig (per departure airport)
└── DynamicFlightCacheRoute (unique route + duration)
└── DynamicFlightCache (pricing per date/CUG)
└── DynamicFlightCacheSegment (flight details)
  1. Route Preparation - Creates route definitions from product flight configs
  2. Cache Entry Creation - Creates pending entries for each searchable date
  3. Search Execution - Calls Aerticket API and stores pricing results
  4. Price Retrieval - Queries completed cache entries for display

Routes Table (dynamic_flight_cache_routes)

Section titled “Routes Table (dynamic_flight_cache_routes)”

Defines unique flight routes for caching. Routes are product-agnostic and identified by flight segments + trip duration.

ColumnTypeDescription
idbigintPrimary key
flight_routejsonbArray of segments with origin, destination, day_offset
trip_duration_dayssmallintTotal trip duration
is_domesticbooleanDomestic flight flag
is_multi_citybooleanMulti-city booking flag
segment_indexsmallintOrder in trip (1-based)
is_activebooleanRoute active for caching
invalidated_attimestampWhen route was auto-invalidated (nullable)

Flight Route JSON Structure:

[
{"origin": "BCN", "destination": "CMB", "day_offset": 0},
{"origin": "CMB", "destination": "MLE", "day_offset": 3},
{"origin": "MLE", "destination": "BCN", "day_offset": 10}
]

day_offset is 0-indexed: 0 is the product departure date itself, 1 is the next day, and so on. The Filament admin UI displays this as “Day N” using day_offset + 1, so the operator-facing label for day_offset=0 is “Day 1”. Cascade target dates are productDeparture + day_offset + cumulativeDelay — see Operator Workflow for how delays propagate.

Indexes:

  • GIN index on flight_route for JSONB queries
  • Unique constraint on (trip_duration_days, is_multi_city)

Cache Entries Table (dynamic_flight_caches)

Section titled “Cache Entries Table (dynamic_flight_caches)”

Stores pricing data for specific dates and routes.

ColumnTypeDescription
idbigintPrimary key
route_idbigintFK to routes table
provider_idbigintFK to providers table
departure_datedateTrip start date
return_datedateTrip end date (nullable)
arrival_datedateArrival date at destination (for land rate matching)
statusvarcharCache entry status
searched_attimestampLast search execution time
cug_typevarcharClosed User Group type
request_identifiervarcharAerticket API flow_id
fare_positionsmallintPrice ranking (1=cheapest)
currencyvarcharPrice currency (EUR)
base_pricenumericBase fare amount
taxnumericTax amount
total_pricenumericTotal price (includes any rolled-in baggage upsell for UPSELLABLE fares)
baggage_includedboolean (nullable)true when the base fare includes a checked bag; false when not; null pre-search
baggage_upsell_pricenumeric (nullable)Cheapest per-passenger ancillary price when baggage must be added; null otherwise
baggage_ancillary_codevarchar (nullable)Aerticket ancillary code (e.g. MBAG) captured at search time for reference
expires_attimestampCache expiration (nullable)
api_response_time_msintegerAPI response time

Indexes:

  • Composite index on (route_id, departure_date, return_date, total_price)
  • Index on status for batch processing
  • Index on cug_type for filtering
  • Index on expires_at for expiration queries
  • Composite index on (route_id, status, fare_position, searched_at) for consecutive empty search queries
StatusDescriptionCan Search
pendingEntry created, awaiting searchYes
searchingAPI call in progressNo
completedSuccessfully cachedNo
failedSearch failedYes
expiredCache expiredYes

Controls how fares without an included checked bag are handled during cache population. Configured via the Flight Search Filters admin page — see Flight Search UI — Search Filter Settings.

CaseBehaviour
OFFDo not filter by baggage. All fares are cached regardless of inclusion.
STRICTOnly cache fares that include checked baggage in the base price. needBaggage=true is sent to Aerticket so the filter happens server-side.
UPSELLABLECache fares that either include baggage natively or to which a checked bag can be added as an ancillary. The per-passenger upsell price is multiplied by the search pax count and rolled into total_price so sorting stays apples-to-apples.

Source: backend/app/Enums/BaggagePolicy.php

Under UPSELLABLE, DynamicFlightCachePopulatorService resolves the baggage state of each no-baggage fare via a three-tier lookup, cheapest tier first:

  1. In-response sibling — search topFares for another fare family with the same itinerary signature, same currency, and hasBaggage() === true (e.g., LATAM’s KD alongside QP for the same flight). Free: no extra round-trip.
  2. /search-upsell (Aerticket Search Upsell) — surfaces alternative fare families Aerticket omits from /search (notably LATAM ED/PL/MF with 1 PC). Call is memoized per itinerary signature so N source fares sharing the same physical flight trigger one round-trip, not N.
  3. /available-fare-ancillaries — legacy path for carriers that publish baggage as an ancillary (BAGGAGE type) rather than a fare family. Cheapest ancillary wins.

The populator applies three independent guards to avoid abusing these endpoints:

GuardValueScope
consumeDailyBudget('search_upsell')80/day (AERTICKET_SEARCH_UPSELL_DAILY_LIMIT)Cross-batch — see AerTicket — Low-Volume Endpoints and Daily Budgets
consumeDailyBudget('available_fare_ancillaries')80/day (AERTICKET_ANCILLARIES_DAILY_LIMIT)Cross-batch
MAX_UPSELL_CALLS_PER_SEARCH3Per-search cap on distinct itinerary signatures probed via /search-upsell
MAX_EMPTY_ANCILLARY_ATTEMPTS3Consecutive empty ancillary responses before circuit-breaking the batch

Resolved fares are deduplicated twice before persistence:

  • By fareId — multiple no-baggage fares can resolve to the same sibling; a later native bag-included fare must not be added twice after being promoted by an earlier sibling swap.
  • By commercial identity/search-upsell returns distinct fareIds for commercially identical offerings (same physical itinerary + currency + final price). The populator builds a canonical key from itinerarySignature, currency, and final price to collapse them.

After resolution, the resolved list is re-sorted by upsell-inclusive total_price so fare positions 1..5 reflect the true customer-facing price ordering.

Source: backend/app/Services/Flights/DynamicFlightCachePopulatorService.php (resolveBaggageForFare, findBagIncludedSibling, findBagIncludedViaUpsell, commercialIdentityKey)

The Latest Arrival Time admin setting (flights.search.latest_arrival_time) is applied as a post-cache visual flag, not a drop at populate time. Operators wanted to distinguish “Aerticket returned nothing” from “Aerticket returned fares, but none comply with our arrival-time policy”, so the populator stores everything and the UI marks the offenders.

DynamicFlightCache::violatesLatestArrivalTime(?string $cutoff): bool returns true when the outbound leg’s final-segment arrival H:i (local) falls outside the daytime window [06:00, cutoff]:

  • HH:MM > cutoff — late (afternoon past cutoff). E.g. 17:30 > 16:00.
  • HH:MM < 06:00 — late (pre-dawn arrival). E.g. 00:10, regardless of cutoff.

Dates are ignored: a next-day 15:30 arrival passes a 16:00 cap because the clock reads 15:30 on landing. The 06:00 floor is hardcoded so overnight landings can’t slip through. Returns false when the cutoff is null/empty or segments are not loaded — the UI treats “unknown” as compliant.

The shared [06:00, cutoff] check lives in FareTimeWindowFilter::hhmmWithinDaytimeWindow() so the model method (cached-row path) and FareTimeWindowFilter::arrivesNoLaterThan() (live-search path) can’t drift.

Important: The offer generator enforces the same cutoff at offer creation time — see Offers — Arrival-Time Compliance. Flagged cached rows exist in the database but are not shipped to customers.

Source: backend/app/Models/DynamicFlightCache.php (violatesLatestArrivalTime), backend/app/Services/Flights/FareTimeWindowFilter.php

Segments Table (dynamic_flight_cache_segments)

Section titled “Segments Table (dynamic_flight_cache_segments)”

Stores detailed flight information for cached fares.

ColumnTypeDescription
idbigintPrimary key
flight_cache_idbigintFK to caches table
leg_sequencesmallintLeg number (1=outbound)
segment_numberintegerSegment within leg
flight_numbervarcharFlight number without carrier prefix (e.g. “1860”)
cabin_classvarcharCabin class
departure_airport_idbigintFK to airports
departure_timetimestampDeparture datetime
arrival_airport_idbigintFK to airports
arrival_timetimestampArrival datetime
operating_carriervarcharOperating airline code
airline_namevarchar(100)Airline display name (nullable)
baggage_allowancevarcharBaggage info
duration_minutesintegerFlight duration

Main service for preparing routes and executing searches.

Source: backend/app/Services/Flights/DynamicFlightCachePopulatorService.php

$service = app(DynamicFlightCachePopulatorService::class);
// Prepare routes for a single config
$result = $service->prepareRoutesForConfig($config);
// Returns: routes_created, routes_reused, segments, errors
// Prepare routes for entire product
$result = $service->prepareRoutesForProduct($product);
// Returns: configs_processed, routes_created, routes_reused, errors
// Create pending entries for date range
$result = $service->createCacheEntriesForConfig($config);
// Returns: entries_created, entries_existing, errors
// Combined: prepare routes AND create entries
$result = $service->prepareForConfig($config);
// Execute all pending searches
$result = $service->executeSearches();
// Execute specific entries
$result = $service->executeSearches(collect([$cacheEntry]));
// Returns: total_searches, successful_searches, failed_searches, fares_cached, segments_cached, errors

Handles caching search results from manual flight searches.

Source: backend/app/Services/Flights/DynamicFlightCacheService.php

$service = app(DynamicFlightCacheService::class);
// Cache results from a search (only if matching route exists)
$faresCached = $service->cacheSearchResults($searchRequest, $searchResponse);

During checkout, a live economy flight search re-prices the offer before the user proceeds. Two services handle this:

EconomyFlightSearchService performs the live Aerticket API search:

  1. Extracts route info (departure, destination, dates) from the offer’s cached OfferFlight segments
  2. Applies admin-configured search filters from the Settings page via SettingsService::buildCheckoutSearchOptions() (airlines, max stops, fare sources, baggage) with forced ECONOMY cabin class
  3. Detects multi-city vs round-trip: multi-city when inbound departure differs from outbound destination (e.g., outbound MAD->PEK but inbound PVG->MAD)
  4. Searches domestic flight legs (one-way per leg) alongside international — failures are graceful with cached prices as fallback
  5. Selects the best fare: cheapest with baggage included, or cheapest overall if none include baggage
  6. Applies optional latest_arrival_time post-filter from admin settings (international only)
  7. Returns all itinerary suboptions per leg (different departure times at the same price)
  8. Batch-loads airports in a single query to avoid N+1

Source: backend/app/Services/Checkout/EconomyFlightSearchService.php

EconomyFlightCacheUpdateService updates the database with fresh results:

  1. Creates new DynamicFlightCache entries for the top 5 fares (sorted by price) for both international and domestic legs
  2. Stores segments with itinerary_index for multiple suboptions per leg
  3. Resolves airport codes to city codes via toCityCode() for route matching (e.g., PEK -> BJS) since DynamicFlightCacheRoute stores city codes but segments store airport codes
  4. Re-links the offer’s OfferFlight records to the selected fare’s new cache entries (international and domestic separately)
  5. Updates individual OfferFlight.price for each leg independently (international + each domestic leg with a live result)
  6. Recalculates the offer’s flight_base_price as international + all domestic fares (using cached price as fallback when domestic search fails)
  7. Safely deletes old cache entries only if they are not linked to any other OfferFlight
  8. Uses saveQuietly() consistently to bypass OfferObserver’s active-offer block

All operations run inside a database transaction. Batch airport loading (single whereIn query) prevents N+1 when storing segments across multiple fares.

Source: backend/app/Services/Checkout/EconomyFlightCacheUpdateService.php

Related endpoint: POST /checkout/{offerId}/search-economy-flights — see Checkout API

When the user selects a business class upgrade, BusinessFlightSearchService performs a live Aerticket API search with the same patterns as economy:

  1. Applies admin-configured search filters via SettingsService::buildCheckoutSearchOptions() with forced BUSINESS cabin class
  2. Detects multi-city vs round-trip based on inbound/outbound airport mismatches
  3. Searches domestic flight legs (one-way BUSINESS per leg) alongside international
  4. If no business fares found for a domestic leg, falls back to the cached economy price (already available from the economy search that runs first)
  5. Sums domestic prices into $domesticTotal, which is added to every business fare card’s “+x€” extra price calculation
  6. Returns all fare options for user selection (unlike economy which auto-selects the best fare)

The searchBusinessFlightsRaw() method is used for post-payment flight rebooking (admin-triggered). It applies the same filters and multi-city detection but skips domestic search (rebooking is for international legs only).

Source: backend/app/Services/Checkout/BusinessFlightSearchService.php

Related endpoint: POST /checkout/{offerId}/business-flights — see Checkout API

  • Maximum fares per search: 5 (positions 1-5 by price)
  • Provider: Aerticket (aerticket code)

Cache entries are created with:

  • CUG Type: ALL (default)
  • Currency: EUR
  • Fare Position: 1-5 (1 = cheapest)

Routes that consistently return zero results from Aerticket are automatically deactivated to prevent wasted L2B (Look-to-Book) API calls.

  1. After each zero-result search, DynamicFlightCacheRoute::checkAndAutoInvalidate() counts consecutive completed entries with total_price = 0.00 (using fare_position = 1 to avoid duplicates)
  2. When the count reaches the configured threshold (default: 5), the route is set to is_active = false with an invalidated_at timestamp
  3. SearchFlightCacheJob eager-loads the route and skips entries whose route is inactive
  4. dispatchSearchJobs() also skips entries with inactive routes before dispatching

Config file: config/dynamic-flight-cache.php -> route_invalidation

KeyEnv VarDefaultDescription
enabledDYNAMIC_CACHE_ROUTE_INVALIDATION_ENABLEDtrueEnable/disable auto-invalidation
consecutive_empty_thresholdDYNAMIC_CACHE_EMPTY_THRESHOLD5Consecutive zero-result searches before deactivation
Stateis_activeinvalidated_atDescription
ActivetruenullNormal operation, receives search jobs
Auto-InvalidatedfalsetimestampDeactivated by the system after threshold reached
Manually InactivefalsenullDeactivated by an admin via the panel

Use wasAutoInvalidated() to distinguish auto-invalidated routes from manually deactivated ones.

Reactivate a route from the admin panel (view page header action or table row action) or programmatically:

$route->reactivate(); // Sets is_active = true, invalidated_at = null

Reactivation clears the invalidated_at timestamp. If the route keeps returning empty results, it will be auto-invalidated again after reaching the threshold.

Source: backend/app/Models/DynamicFlightCacheRoute.php

The current implementation stores cache entries without automatic expiration (expires_at = null). Entries are manually refreshed through:

  1. Manual Refresh - Admin triggers re-search
  2. Status-Based Refresh - Re-search pending, failed, or expired entries
// Get non-expired entries
DynamicFlightCache::active()->get();
// Get expired entries
DynamicFlightCache::expired()->get();
// Check if entry is expired
$cache->isExpired(); // false if expires_at is null

The DynamicFlightRefreshSchedule model supports scheduled cache refresh with different triggers:

TriggerDescription
scheduledRegular TTL-based refresh
gap_triggeredPrice gap detection
sales_triggeredSales event
manualOperator-initiated
calibrationAccuracy measurement

Source: backend/app/Models/DynamicFlightRefreshSchedule.php

// Find due schedules
$schedules = DynamicFlightRefreshSchedule::due()->get();
// Execute and mark complete
$schedule->markRunning();
// ... execute refresh ...
$schedule->markExecuted();

Helper methods extract timing information from the first leg segments:

$cache = DynamicFlightCache::find(1);
// First segment departure time
$cache->getOutboundDepartureTime(); // Carbon instance or null
// Last segment arrival time (at destination)
$cache->getOutboundArrivalTime(); // Carbon instance or null
// Total duration in minutes
$cache->getOutboundDurationMinutes(); // int or null
// Formatted duration
$cache->getFormattedOutboundDuration(); // "14h 30m" or null

Source: backend/app/Models/DynamicFlightCache.php

use App\Models\DynamicFlightCache;
use App\Enums\FlightCugType;
$totalPrice = DynamicFlightCache::getTotalFlightPrice(
flightRoute: 'BCN -> CMB, CMB -> MLE, MLE -> BCN',
tripDuration: 11,
tripStartDate: Carbon::parse('2025-03-01'),
cugType: FlightCugType::ALL
);
// Filter by status
DynamicFlightCache::pending()->get();
DynamicFlightCache::completed()->get();
DynamicFlightCache::searchable()->get(); // pending, failed, expired
// Filter by route type
DynamicFlightCache::domestic()->get();
DynamicFlightCache::international()->get();
// Filter by CUG type
DynamicFlightCache::forCug(FlightCugType::ALL)->get();
// Filter by date range
DynamicFlightCache::forDepartureDateRange($from, $to)->get();

The primary operator-facing flow lives inline on the ProductByMarket view page as a “Dynamic Flight Cache” section. It replaces the previous “Cache Matching Flights” header action and is now the recommended way to drive ad-hoc searches for a specific product.

Visibility: Section is rendered only when the product has at least one active flight config and both search_start_date and search_end_date are set.

Source: backend/app/Livewire/Filament/ProductsByMarket/DynamicFlightCacheManager.php, backend/app/Filament/Resources/ProductsByMarket/Schemas/ProductByMarketInfolist.php (Section::make('Dynamic Flight Cache'))

Two multi-select fields drive the cascade:

FieldSource
Departure airportsAll airports of the product’s active flight configs. Pre-selected on mount; deselect to narrow.
Departure datesEvery date in [search_start_date, search_end_date] that is not in the product’s excluded_dates.

Clicking Search Flights dispatches one CascadeFlightSearchForDateJob per (active config × selected date) to the flight-searches queue. Dates outside the product’s search window or in the excluded list are skipped with a warning notification.

CascadeFlightSearchForDateJob walks one config’s legs in day_offset order for one departure date, executing the matching DynamicFlightCache row for each leg sequentially and folding each leg’s actual arrival back into a running cumulativeDelay. Downstream legs are searched at productDeparture + leg.day_offset + cumulativeDelay, so an overnight international arrival pushes every domestic leg one day forward and matches the cache rows that the auto-offer generator’s findDomesticFlights() will later look for (see Offers — Domestic Leg Date Derivation).

Source: backend/app/Jobs/CascadeFlightSearchForDateJob.php, backend/app/Services/Flights/LegDelayCascadeResolver.php

Configuration:

  • Queue: flight-searches
  • Tries: 1 (no retry — partial cascades are visible in the activity log)
  • Timeout: 600 seconds (sized for ~3-4 sequential Aerticket searches at ~30 s each)

The job stops walking the leg list (and leaves downstream legs as PENDING) when any of these conditions hit. The stop reason is logged to both Log::warning() and the flight-searches activity log with a cascade_partial event.

ReasonCause
route_not_foundNo active DynamicFlightCacheRoute matches the leg under its config + trip duration
target_date_outside_search_windowCumulative delay pushed the leg past search_end_date or onto an excluded date
cache_entry_missingNo DynamicFlightCache row exists for the resolved (route, target_date) — operator needs to run “Prepare Flight Searches” first
search_failedexecuteSearchForEntry() threw
search_not_completedCache row ended in a status other than COMPLETED
no_resultsAerticket returned an empty fare set — the row is COMPLETED but total_price=0 and has no segments, so getOutboundArrivalTime() is null and the cascade can’t compute the next leg’s date

The no_results halt is load-bearing: a connection that Aerticket cannot price stops the cascade outright, so downstream domestics are not searched and stay PENDING. This is intentional — burning API calls on a leg whose international predecessor isn’t actually flyable wastes L2B budget.

Below the form, the section renders the same table component as the standalone Dynamic Flight Caches list page (DynamicFlightCachesTable::configure()), so all columns, filters, and the arrival-compliance + baggage badges documented under Admin Interface apply identically here.

Three behaviours are specific to the inline view:

  • Polls every 5 s so cascade progress streams in without manual refresh.
  • Filters to fare_position = 1 — only the cheapest fare per (route, date) is shown, since the table is for monitoring cascade execution rather than browsing fare ladders.
  • Per-route target dates shift with the cascade. The component reuses LegDelayCascadeResolver to walk each (config, productDeparture) pair the same way the job does: the international leg appears at the operator-selected date, but each downstream domestic leg appears at the date the cascade will search once the international row completes. While the international leg is still PENDING, downstream legs use a zero further-delay assumption (planned best-case) and snap to the real shifted date once the international result lands. Rows are ordered by leg trigger order via a CASE route_id WHEN ... THEN day_offset expression so the international leg surfaces first, followed by domestics in day_offset order.

Source: backend/app/Livewire/Filament/ProductsByMarket/DynamicFlightCacheManager.php (scopeTableQuery, resolveCascadePlan)

Two Filament resources provide access to flight cache data, both under “Inventory Management” navigation group.

URL: /admin/dynamic-flight-caches

Browse and manage individual cache entries (pricing records).

Features:

  • Route tabs: Top 10 most-used routes displayed as quick-filter tabs (hidden when filtering by product)
  • Product route tabs: When navigating from ProductByMarket, shows an aggregate “Product Routes” tab plus individual route tabs (replaces the top-10 tabs)
  • Searchable route column with database-aware query (ILIKE for PostgreSQL, LIKE for SQLite)
  • Default grouping by route with collapsible groups showing route name and duration
  • Searchable multi-select route filter (replaces text input)
  • Execute searches for pending entries
  • Arrival Compliance badge — computed against flights.search.latest_arrival_time. OK (green check) when within [06:00, cutoff], Late (> HH:MM) (red warning) when the outbound-leg final arrival violates the window, (gray) when no cutoff is configured. Computed via DynamicFlightCache::violatesLatestArrivalTime().
  • Baggage column — green Included / allowance string when the base fare covers a checked bag, yellow Upsell +CUR X.XX when the upsell price was rolled in, gray Not included otherwise.

URL Parameters:

  • ?product_routes=12,13,14 - Filter to product-specific routes (comma-separated IDs, shows individual route tabs)

Source: backend/app/Filament/Resources/DynamicFlightCaches/

URL: /admin/flight-cache-routes

Browse routes (unique flight patterns) and their associated cache entries.

Features:

  • Route-centric view with flight segments, duration, domestic/international flags
  • Three-state status column: Active (green), Auto-Invalidated (warning), Inactive (red)
  • Route Health infolist section showing consecutive empty search count and invalidation timestamp
  • View page with infolist, cache statistics, and caches relation manager
  • Header actions: Reactivate (for inactive routes) and Deactivate (for active routes)
  • Table row action: Reactivate (for inactive routes)
  • Default filter shows active routes only
  • invalidated_at column available as toggleable column

Source: backend/app/Filament/Resources/DynamicFlightCacheRoutes/

The ProductByMarket view page includes a “View Cached Flights” action (warning color, server stack icon) that links directly to the DynamicFlightCaches resource filtered by all matching routes.

Visibility: Only shown when at least one active flight config has a matching DynamicFlightCacheRoute.

URL Generation:

Always uses ?product_routes=12,13 with comma-separated route IDs, so the list page shows product-specific route tabs instead of the global top-10.

This supports products with multiple departure airports (BCN, MAD) and domestic flights, collecting ALL matching route IDs from active flight configs.

Source: backend/app/Filament/Resources/ProductsByMarket/Pages/ViewProductByMarket.php

Execute Searches (Queue) - Dispatch selected entries to the flight-searches queue:

Source: backend/app/Filament/Resources/DynamicFlightCaches/Actions/ExecuteSearchesBulkAction.php

Execute Single Search - Process individual entry:

Source: backend/app/Filament/Resources/DynamicFlightCaches/Actions/ExecuteSingleSearchAction.php

Prepare Flight Searches - Create routes and entries from product:

Source: backend/app/Filament/Resources/ProductsByMarket/Actions/PrepareFlightSearchesAction.php

Immutable value object representing a multi-segment flight route.

Source: backend/app/ValueObjects/FlightRoute.php

// Create from string
$route = FlightRoute::fromString('BCN -> CMB, CMB -> MLE');
// Create from array
$route = FlightRoute::fromArray([
['origin' => 'BCN', 'destination' => 'CMB', 'day_offset' => 0],
['origin' => 'CMB', 'destination' => 'MLE', 'day_offset' => 3],
]);
// Query methods
$route->getOrigin(); // 'BCN'
$route->getDestination(); // 'MLE'
$route->getSegmentCount(); // 2
$route->isMultiSegment(); // true
$route->containsSegment('BCN', 'CMB'); // true

The FlightRouteConfigGenerator analyzes product template itineraries to generate flight configuration options.

Source: backend/app/Services/Flights/FlightRouteConfigGenerator.php

Option A: Multi-city + Domestic Flights (Hybrid)

Section titled “Option A: Multi-city + Domestic Flights (Hybrid)”

One API call for international multi-city, plus individual calls for domestic flights extracted from itinerary routes with mode='flight'.

Example Route (Thailand trip from Madrid):

International multi-city: MAD → BKK → HKT → MAD (1 API call)
Domestic flights: BKK → CNX, CNX → KBV (2 API calls)
Total: 3 API calls

When to use:

  • Most cost-effective for multi-destination trips
  • Domestic flights are derived from itinerary routes with mode: 'flight'

Route creation:

  • 1 route with is_multi_city=true, is_domestic=false for international
  • N routes with is_multi_city=false, is_domestic=true for domestic

Each leg is searched individually as separate one-way flights.

Example Route:

MAD → BKK (international)
BKK → CNX (domestic)
CNX → KBV (domestic)
KBV → MAD (international)
Total: 4 API calls

Route creation:

  • 1 route per leg with is_multi_city=false
  • is_domestic flag set based on flight_type of each leg

Main airports are prioritized when resolving cities to airports:

// Prioritizes iata_code = iata_city_code
// Bangkok: BKK (Suvarnabhumi) preferred over DMK (Don Mueang)
Airport::where('city', 'ILIKE', $city)
->orderByRaw('CASE WHEN iata_code = iata_city_code THEN 0 ELSE 1 END')
->first();

The generator tracks the final itinerary location for the return flight, regardless of transport mode. If the last leg uses surface transport (ARNK), the return still departs from the actual final location.

Example: Itinerary ends in Phuket via ground transport from Krabi → Return flight departs from HKT (Phuket), not KBV (Krabi).

For processing large batches of searches, use the queue-based approach instead of synchronous execution.

Executes a single flight search asynchronously via the flight-searches queue.

Source: backend/app/Jobs/SearchFlightCacheJob.php

Behavior:

  • Eager-loads the route relationship and skips entries whose route is inactive (deactivated or auto-invalidated)
  • On zero-result searches, triggers route auto-invalidation check (see Route Auto-Invalidation)

Configuration:

  • Queue: flight-searches
  • Tries: 3
  • Timeout: 90 seconds
  • Backoff: 30s, 60s, 120s

Usage:

use App\Jobs\SearchFlightCacheJob;
// Dispatch single search
SearchFlightCacheJob::dispatch($cacheId, $userId)
->onQueue('flight-searches');
// Dispatch via service (recommended)
$service = app(DynamicFlightCachePopulatorService::class);
$result = $service->dispatchSearchJobs($cacheEntries, $userId);
// Returns: ['dispatched' => int]

Select cache entries in the Dynamic Flight Caches table and use Execute Searches bulk action. This dispatches jobs to the queue with user attribution for activity logging.

All flight search operations are logged to both the database (Activity Logs) and structured logs (Grafana Loki).

Centralized service for consistent logging across all flight search operations.

Source: backend/app/Services/Flights/FlightSearchActivityLogger.php

Log Name: flight-searches

EventTriggerSubject
preparedPrepareFlightSearchesActionProductByMarket
dispatchedExecuteSearchesBulkActionNone (batch operation)
completedSearchFlightCacheJob successDynamicFlightCache
failedSearchFlightCacheJob failureDynamicFlightCache

All logs include:

  • action - Event type (prepare_searches, dispatch_searches, search_completed, search_failed)
  • user_id, user_name - User attribution
  • cache_id, route_id - Resource identifiers
  • route - Flight route string
  • departure_date, return_date - Search dates
use Spatie\Activitylog\Models\Activity;
// Get all flight search activities
Activity::inLog('flight-searches')->get();
// Get activities for specific user
Activity::inLog('flight-searches')
->causedBy($user)
->get();
// Get activities for specific cache entry
Activity::inLog('flight-searches')
->forSubject($cacheEntry)
->get();
// Get recent failures
Activity::inLog('flight-searches')
->where('event', 'failed')
->latest()
->limit(10)
->get();
# All flight search operations
{app="volare"} |= "flight-searches"
# Failed searches
{app="volare"} | json | action="search_failed"
# Searches by user
{app="volare"} | json | user_id="123"
# Specific route
{app="volare"} | json | route=~".*BCN.*CMB.*"
  • Maximum 5 fares cached per search (positions 1-5 by price)
  • Routes are product-agnostic - shared across products with same flight pattern
  • Day offsets determine actual flight dates from trip start date
  • Multi-city routes stored as single combined entry for international legs
  • Domestic routes stored separately for individual pricing
  • CUG type filtering - searches can target specific fare groups
  • Main airports prioritized - BKK over DMK when resolving cities
  • Routes auto-invalidated after 5 consecutive empty searches (configurable) to save L2B API costs

Issue: DMK selected instead of BKK for Bangkok

Cause: Query returning first match instead of main airport

Solution: Verify airport has matching iata_code and iata_city_code:

SELECT * FROM airports
WHERE city ILIKE 'Bangkok'
ORDER BY CASE WHEN iata_code = iata_city_code THEN 0 ELSE 1 END;

Issue: Return flight departing from Krabi instead of Phuket

Cause: Last destination in itinerary reached by ARNK (surface transport), not tracked

Solution: The generator now always includes final itinerary location regardless of transport mode.

Domestic Flights Not Appearing in Option A

Section titled “Domestic Flights Not Appearing in Option A”

Issue: Option A shows only international legs

Cause: Itinerary routes missing mode: 'flight'

Check: Verify itinerary has routes with mode='flight':

$itinerary = $template->itinerary;
foreach ($itinerary as $item) {
$routes = $item['routes'] ?? [];
foreach ($routes as $route) {
if ($route['mode'] === 'flight') {
// This creates a domestic leg
}
}
}

Issue: Option B creates routes with wrong is_domestic flag

Cause: Flight leg flight_type not set correctly

Check: Verify legs have correct flight_type (‘international’, ‘domestic’, ‘arnk’):

$config->legs->each(fn ($leg) => dump($leg->flight_type));