Flight Search UI
Native FilamentPHP v5 implementation of flight search interface with interactive leg selection, collapsible panels, and component-based architecture.
Quick Start
Section titled “Quick Start”Accessing Flight Search
Section titled “Accessing Flight Search”URL: /admin/flight-searches/flights
Navigation: Admin Panel → Flight Searches → Flights (Aerticket)
Basic Search
Section titled “Basic Search”- Select departure and arrival airports (searchable dropdowns)
- Choose dates and passenger counts
- Configure fare options and preferences
- Click “Search Flights”
- Browse results with interactive leg selection
- View fare details and book flights
Architecture
Section titled “Architecture”Page Structure
Section titled “Page Structure”File: app/Filament/Resources/FlightSearches/Pages/SearchFlights.php
Components:
- Form - Search criteria using FilamentPHP Schema
- Table - Results display using FilamentPHP Table Builder
- Actions - Search, clear, and fare details actions
- Livewire Methods - Interactive leg selection
Component Hierarchy
Section titled “Component Hierarchy”SearchFlights (Page)├── Form (Schema)│ ├── Section: Flight Search│ │ ├── Grid: Origin/Destination/Dates│ │ ├── Grid: Passengers/Cabin│ │ ├── Section: Fare Options│ │ ├── Section: Flight Preferences│ │ └── Section: Airline Filtering├── Table│ ├── Split: Fare Summary│ │ ├── Stack: Price Info│ │ └── Split: Badges (Stops, Duration, Source)│ └── Panel: Collapsible Flight Legs│ ├── Stack: Outbound Options│ │ └── Split: Radio + Leg Details (clickable)│ └── Stack: Return Options│ └── Split: Radio + Leg Details (clickable)└── Actions ├── searchFlights() ├── clearResults() └── viewFareDetails()Search Form
Section titled “Search Form”Trip Type Selection
Section titled “Trip Type Selection”Radio::make('trip_type') ->label('Trip Type') ->options([ 'one_way' => 'One Way', 'round_trip' => 'Round Trip', 'multi_city' => 'Multi-City', ]) ->default('one_way') ->inline() ->live() ->columnSpanFull()Reactive Behavior:
- Return date field visibility tied to
trip_type === 'round_trip' - Multi-city segments visible when
trip_type === 'multi_city'
Multi-City Segments
Section titled “Multi-City Segments”Repeater::make('segments') ->label('Trip Segments') ->visible(fn (Get $get): bool => $get('trip_type') === 'multi_city') ->minItems(2) ->maxItems(6) ->schema([ Grid::make(3) ->schema([ Select::make('departure_airport'), Select::make('arrival_airport'), DatePicker::make('departure_date'), ]), ])Constraints:
- Minimum 2 segments, maximum 6 segments
- All segment dates must be in chronological order
- Validated at search time
Passenger Configuration
Section titled “Passenger Configuration”Grid::make(4) ->schema([ TextInput::make('adults') ->numeric() ->default(1) ->minValue(1) ->maxValue(9),
TextInput::make('children') ->label('Children (2-11)') ->default(0),
TextInput::make('infants') ->label('Infants (0-1)') ->default(0),
Select::make('cabin_class') ->options([ 'ECONOMY' => 'Economy', 'ECONOMY_PREMIUM' => 'Premium Economy', 'BUSINESS' => 'Business', 'FIRST' => 'First', ]), ])Validation:
- Total passengers (adults + children + infants) ≤ 9
- At least 1 adult required
Fare Options
Section titled “Fare Options”CheckboxList::make('fare_sources') ->options([ 'FSC_IATA' => 'IATA Published Fares', 'FSC_NEGO' => 'Negotiated Fares', 'FSC_CONSO' => 'Consolidator Fares', 'FSC_WEB' => 'Direct Channel & Low-Cost Fares', ]) ->default(['FSC_IATA'])Airline Filtering
Section titled “Airline Filtering”TagsInput::make('included_airlines') ->label('Include Airlines') ->placeholder('Enter airline codes (e.g., LH, BA, AA)') ->separator(',')
TagsInput::make('excluded_airlines') ->label('Exclude Airlines') ->separator(',')Validation:
- IATA codes: 2-3 alphanumeric characters
- Cannot have overlapping included/excluded airlines
Results Display
Section titled “Results Display”Component-Based Architecture
Section titled “Component-Based Architecture”Split::make([ // Left side: Price and fare information Stack::make([ TextColumn::make('formattedPrice') ->weight(FontWeight::Bold) ->color('success'), TextColumn::make('fareType'), TextColumn::make('cabinClass') ->icon('heroicon-m-ticket'), ])->space(1),
// Right side: Badges Split::make([ TextColumn::make('numberOfStops') ->badge() ->color(fn (int $state): string => match ($state) { 0 => 'success', 1 => 'warning', default => 'danger', }), TextColumn::make('formattedDuration') ->badge() ->icon('heroicon-m-clock'), ])->from('sm'),])->from('md')Interactive Leg Selection
Section titled “Interactive Leg Selection”Users can select different flight options for each direction:
Panel::make([ Stack::make(fn ($record) => $this->buildFlightLegComponents($record)) ->space(3),])->collapsible()->collapsed(true)Leg Structure (Round-Trip):
Panel (Collapsible)├── Outbound Header├── Leg Option 1 (Split: Radio + Details) [Clickable]├── Leg Option 2 (Split: Radio + Details) [Clickable]├── Return Header├── Leg Option 1 (Split: Radio + Details) [Clickable]└── Leg Option 2 (Split: Radio + Details) [Clickable]Leg Structure (Multi-City):
Panel (Collapsible)├── Leg 1 Header├── Leg Option 1 [Clickable]├── Leg 2 Header├── Leg Option 1 [Clickable]└── ... (up to 6 legs)Livewire Interactivity
Section titled “Livewire Interactivity”public function selectLeg(string $fareId, string $direction, int $legIndex): void{ if (!isset($this->selectedLegs[$fareId])) { $this->selectedLegs[$fareId] = [ 'Outbound' => 0, 'Return' => 0, ]; }
$this->selectedLegs[$fareId][$direction] = $legIndex;}Actions
Section titled “Actions”View Ancillaries
Section titled “View Ancillaries”ViewAncillariesAction::make() ->rawFareData($this->rawFareData) ->selectedLegs($this->selectedLegs)Features:
- Opens modal with available ancillary services
- Component-level caching prevents duplicate API requests
- Displays services grouped by type (BAGGAGE, MEAL, SEAT)
View Fare Details
Section titled “View Fare Details”ViewFareDetailsAction::make() ->rawFareData($this->rawFareData)Features:
- Complete fare breakdown
- Passenger pricing
- Baggage allowance
- Booking action
Upsell Action
Section titled “Upsell Action”UpsellAction::make() ->rawFareData($this->rawFareData)Features:
- Searches for upgraded fare options
- Uses user’s selected leg choices
- Replaces search results with upsell results
Responsive Design
Section titled “Responsive Design”Breakpoints
Section titled “Breakpoints”sm- 640pxmd- 768pxlg- 1024px
Usage:
Split::make([/* ... */])->from('md') // Horizontal on medium+Stack::make([/* ... */]) // Always verticalMobile Layout
Section titled “Mobile Layout”- Full-width fields on mobile
- Grid collapses to single column
- Collapsible panels for details
- Touch-friendly input sizes
Performance
Section titled “Performance”Airport Search
Section titled “Airport Search”- Limit to 10 results for dropdowns
- Uses GIN index for sub-millisecond queries
- See Airport Search documentation
Ancillary Caching
Section titled “Ancillary Caching”- Component-level cache prevents duplicate API requests
- Cache key:
fareId|itinerary1:itinerary2 - Automatic invalidation on search state changes
Memory Management
Section titled “Memory Management”public function clearResults(): void{ $this->searchResults = collect(); $this->rawFareData = []; $this->selectedLegs = []; $this->fareAncillaryCache = []; $this->hasSearched = false;}Testing
Section titled “Testing”Manual Testing
Section titled “Manual Testing”# Access flight search page# Test scenarios:1. One-way search: BCN → MAD, 1 adult, Economy2. Round-trip search: LHR → JFK, 2 adults + 1 child, Business3. Multi-city search: BCN → BKK → CNX → BCN, 2 adults4. Multi-leg selection: Expand panel, click different options5. Fare details: Click "View Details" action6. Validation: Try >9 passengersComponent Testing
Section titled “Component Testing”use function Pest\Livewire\livewire;
test('flight search form renders correctly', function () { livewire(SearchFlights::class) ->assertFormExists() ->assertFormFieldExists('departure_airport') ->assertFormFieldExists('arrival_airport') ->assertFormFieldExists('departure_date') ->assertFormFieldExists('adults');});
test('leg selection updates state', function () { $component = livewire(SearchFlights::class); $component->selectLeg('fare-123', 'Outbound', 1);
expect($component->selectedLegs['fare-123']['Outbound'])->toBe(1);});Troubleshooting
Section titled “Troubleshooting”No Search Results
Section titled “No Search Results”- Check API credentials configured
- Verify AerTicket service accessible
- Validate airport codes
- Check date range (max 11 months)
php artisan aerticket:test-connectionphp artisan pail --filter="Aerticket"Leg Selection Not Working
Section titled “Leg Selection Not Working”- Check Livewire scripts loaded
- Check browser console for errors
- Verify
wire:clickattribute present
Search Filter Settings
Section titled “Search Filter Settings”Admin-configured filters applied to all automated flight cache searches (the populator cycle). They do NOT affect the manual Flight Search page or the route validation checks.
The same page holds the global flight ranking (max layover, sort order, tie-break) and, collapsed, the fixed rules that are not configurable.
A product can narrow the results (fewer max stops, extra excluded airlines, its own latest arrival time, its own ranking): international flights per departure airport, domestic flights once for every airport. Those result filters are never sent to Aerticket: the request stays global and the product only narrows the returned flights, in the offer generator and the checkout live searches.
URL: /admin/settings/flight-search-filters
Source: backend/app/Filament/Pages/Settings/FlightSearchFiltersSettings.php, backend/app/Services/SettingsService.php
Persisted Settings
Section titled “Persisted Settings”| Setting key | Type | Default | Purpose |
|---|---|---|---|
flights.search.baggage_policy |
string (BaggagePolicy) |
UPSELLABLE |
Baggage handling policy — see below |
flights.search.max_stops |
int | 2 |
Maximum stopovers per leg requested from Aerticket (ignored when direct_flights_only is on) |
flights.search.direct_flights_only |
bool | false |
Non-stop only; overrides max_stops |
flights.search.cabin_class_list |
list | [] |
Cabin classes (empty = all) |
flights.search.excluded_airline_list |
list | [] |
IATA codes to exclude |
flights.search.airline_alliance_list |
list | [] |
Preferred alliances (empty = all) |
flights.search.fare_source_list |
list | IATA + NEGO + CONSO + WEB | Fare sources to query |
flights.search.closed_user_group_list |
list | [ALL, TOP] |
Closed user groups (see below) |
flights.search.latest_arrival_time |
H:i string |
null |
Post-cache arrival compliance cutoff |
flights.ranking |
json | see Configurable ranking | Global max layover and per-scope sort order, tie-break, hour value. Applied to results only, never sent to Aerticket |
Baggage Policy (baggage_policy)
Section titled “Baggage Policy (baggage_policy)”Select one of OFF / STRICT / UPSELLABLE. Controls how fares without an included checked bag are handled during populator runs:
OFF— cache everything, never filter by baggage.STRICT— only cache fares with a bag in the base price. SendsneedBaggage=trueto Aerticket so the filter runs server-side.UPSELLABLE(default) — cache fares that include a bag or have a bag-included sibling for the same physical itinerary inside the same/searchresponse.
See Dynamic Flight Cache — Baggage Resolution for the /search-only resolution flow.
Closed User Groups (closed_user_group_list)
Section titled “Closed User Groups (closed_user_group_list)”Union filter over Aerticket CUG codes. The API rejects an empty list, so clearing all options falls back to [ALL, TOP] (Cockpit’s default-plus-tour-operator view).
ALL— public bucket Cockpit shows by default.TOP— Tour Operator bucket Cockpit hides behind a dropdown. Surfaces consolidator/tour-operator fares that only appear when TOP is explicitly selected in Cockpit. Included by default so Volāre can undercut Cockpit’s default “best price”.ETH— ethnic fares.CRU— cruise fares.
Latest Arrival Time (latest_arrival_time)
Section titled “Latest Arrival Time (latest_arrival_time)”H:i cutoff flagging fares whose outbound-leg destination arrival falls outside the daytime window [06:00, cutoff]. Local-clock comparison only — dates are ignored:
HH:MM > cutoff→ flagged. A 17:30 arrival with a 16:00 cap is late.HH:MM < 06:00→ flagged. Any pre-dawn landing (00:00–05:59) is late regardless of cutoff.- Next-day 15:30 with a 16:00 cap → compliant. The clock reads 15:30 on landing.
The populator does not drop flagged fares. They are cached and flagged so operators can distinguish “Aerticket returned nothing” from “Aerticket returned fares that don’t comply”. The admin list shows an Arrival Compliance badge computed from this setting.
Two places apply the cutoff:
- The admin list, via
DynamicFlightCache::violatesLatestArrivalTime()— visual flag only. - The auto offer generator, via
AutoOfferGeneratorService::rejectNonCompliantArrivals()— drops flagged cached rows before offer creation.
Both use the shared FareTimeWindowFilter::hhmmWithinDaytimeWindow() so the two paths can’t drift. Leave the setting empty to disable both. A product’s own international latest arrival time, when set, replaces this cutoff for that product in the generator and the checkout economy search, and is the only arrival cutoff the checkout business search applies (see result filters).
Related:
- Dynamic Flight Cache — Arrival-Time Compliance Flag — semantics and admin UI.
- Offers — Arrival-Time Compliance — offer generator behaviour.
Flight Ranking Policy
Section titled “Flight Ranking Policy”FlightRankingPolicy is the single source of truth for “which flight is
better” across the cache populator, the auto-offer generator, the upgrade
service, the flights:rerank-cache console command, and the live-search at
checkout. All paths share the same hard filters and sort through the same
configurable ranking, so the generator’s pick and the
live-search winner for a product + departure airport stay aligned. The optional
stop-tier prune is not universal: the cache populator and the offer
generator deliberately skip it (see step 2) so their selection matches the
checkout live-search winner, which follows the ranking only. Pareto
(duration×price) pruning still runs on the back-office paths that need it;
checkout display keeps visible trade-offs after the hard filters.
The rules were agreed with operators (issue #1779) and replace the previous 5-level lexicographic order (stops → layover → outbound flying time → price → departure):
- Filter — drop any fare that violates baggage, max stops,
max layover, return departure, departure window, or nights-at-destination
rules for the relevant caller. Max layover is the global
ranking setting (8 hours by default,
FlightRankingPolicy::globalMaxLayoverMinutes()); a product can set a shorter one. Direct flights pass the layover check trivially. The filters run before sorting. - Stop-tier prune (opt-in — not applied everywhere) — keep the
lowest stop-count tier available in the valid result set. If 1-stop
options exist, 2-stop options are hidden; if the route only has 2-stop
options, they remain visible. This prune is price-blind — it drops
a fare purely for having more stops, before price is ever considered —
so it runs only on the operator Flight Search page
(
pruneToBestStopCountFareResults) and as the checkout display frontier on the live-search option lists. The cache populator (DynamicFlightCachePopulatorService) and the offer generator (AutoOfferGeneratorService) skip it on purpose: on complex multi-city long-haul routes it discarded a much cheaper multi-stop fare in favour of a fewer-stop one, making cached/generated offer prices diverge from what the checkout live-search actually sells. Skipping it makes those two paths select the same way checkout does (price-first after duration). - Order — the configured sort order, by default total round-trip
duration, gate-to-gate, summed across outbound + return. Includes both
flight time and layover gaps. For cache rows this uses
LegDuration::forLeg(timezone-aware where airport IANA timezones are known, falls back to per-segment + layover sum). For liveFareResultDTOs it sumssegment.duration + layover gapsdirectly — timezone-safe since layover gaps are at the same airport. - Tiebreaks — the configured tie-break (price by default), then the fixed fallback order: duration, price, fewer stops, earliest outbound arrival at destination.
- Prune (Pareto) —
pruneDominatedCacheFares/pruneDominatedFareResultsdrop any fare that is both longer and more expensive than any other fare in the list (with at least one strict inequality), keeping the ranked order. The check runs against the whole list, so it gives the same survivors whatever the sort order. Longer-but-cheaper fares stay. This subsumes the operator rule “a cheaper direct erases longer pricier stopovers” — no special case needed.
Lists are ranked with sortCacheFlights() (DB rows) and sortFareResults()
(live DTOs), both taking a FlightRanking. The comparators compareFlightCache
/ compareFareResult remain with the default order (duration, price,
stops, arrival) for the places that must not follow the setting: the
upgrade service’s Pareto guard
and the fixed order of domestic cache rows.
Configurable ranking
Section titled “Configurable ranking”Which returned flight comes first is a setting, not code. It decides which flight an offer binds and which fare checkout pre-selects, and so, through the bound flight’s arrival date, which dates get a land price. It is applied after Aerticket answers and never changes the search request.
Global — the Ranking section of the Flight Search Filters page, stored
as the flights.ranking json setting (SettingsService::getFlightRanking() /
updateFlightRanking()):
| Field | Scope | Default | Rule |
|---|---|---|---|
max_layover_hours |
every route | 8 | 1–24 h. Drops a fare when no itinerary of a leg keeps every connection within it |
sort_order |
International / Domestic | International: duration · Domestic: price |
App\Enums\FlightSortOrder: duration (Shortest trip), price (Cheapest), best_value (Best value) |
tie_break |
International / Domestic | International: price · Domestic: duration |
App\Enums\FlightTieBreak: duration, price, fewest_stops, earliest_arrival |
hour_value_percent |
International / Domestic | 1 | Used only by Best value |
The defaults reproduce the order that was hardcoded before (international shortest trip then cheapest, domestic the cheapest in-window row, 8 h layover).
Best value ranks each fare by its cost, lowest first:
(price − cheapest price in the list) + (hours over the shortest duration) × hour_value_percent / 100 × the fare's own price. At 1 %, an extra hour is worth 3 € on a 300 €
fare and 12 € on a 1,200 € one. Fares without segment data sort last.
Per product — the same fields can be overridden per scope in the product’s
result filters
(international per departure airport, domestic once for every airport);
anything left empty follows Global. A product’s max layover can only be
shorter than Global (a longer one is capped) and, unlike the order fields,
it drops flights with no fallback. It is checked on the itineraries the customer
is shown: the primary itinerary of a cached international row, the itinerary picked per leg
for a live fare (passesRankingMaxLayoverCache() / passesRankingMaxLayoverFare()), and the
in-window itinerary bound for a domestic leg.
| Caller | Ranking used |
|---|---|
| Auto offer generator, international fare | Product + airport international ranking and max layover |
| Auto offer generator, domestic leg | Product + airport domestic ranking and max layover (see Offers) |
| Checkout economy and business live search | Product + airport international ranking and max layover; economy binds the first ranked fare |
| Diagnose Missing Offers | Product + airport international ranking (gate passes_max_layover, survivor order) |
| Cache populator | Global international ranking on international routes (orders the fares fed to baggage resolution and the Pareto prune); domestic routes keep the fixed duration-first comparator order, because a cache route is shared by every product. The stored fare_position is then assigned by the populator’s own order (arrival-cutoff violators last, then stops, layover, outbound duration, price) |
flights:rerank-cache |
Rewrites fare_position with the global international ranking on international routes and the fixed duration-first comparator order on domestic ones; arrival-cutoff violators go last |
| Admin manual Search Flights page | Global international ranking |
FlightRanking (backend/app/Services/Flights/Domain/FlightRanking.php) is the
value object (global(), forConfig(), defaults(), sortKeys());
ProductByMarketFlightConfig::ranking() and Offer::flightRanking() resolve
it for a config or an offer. GlobalFlightRanking reads the setting once per
request or queue job (bound as scoped), so a long-running worker picks up a
change on its next job.
Changing the ranking does not re-point existing offers. New generation, rebinds and checkout use it from then on, so an offer can bind a different flight (and arrival date) than before only when one of those runs.
Source: backend/app/Services/Offers/FlightRankingPolicy.php
Where it’s applied
Section titled “Where it’s applied”| Caller | Filter | Order | Prune | Visual dedup |
|---|---|---|---|---|
DynamicFlightCachePopulatorService (assigns fare_position) |
yes | yes | yes (Pareto duration×price, international routes only; no stop-tier prune) | no |
AutoOfferGeneratorService (picks the offer’s flight) |
yes — max stops and excluded airlines come from the product’s result filters, applied before the prune | yes | yes (Pareto duration×price, product matches and fallback pruned separately; no stop-tier prune) | no |
OfferFlightUpgradeService::upgradeIfBetter |
no | no | Pareto (duration, price) guard against current bound row — bypassable by checkout live-winner binding. See Offer Flight Upgrade | no |
flights:rerank-cache console command |
yes | yes | yes (Pareto duration×price; no stop-tier prune) | no |
EconomyFlightSearchService::rankDisplayFares / bindFirstDisplayFare (checkout live winner) |
yes — same checkout policy plus baggage resolution and the product’s result filters | yes | yes — stop-count display frontier after baggage | yes — selected fare must be bindable |
EconomyFlightSearchService::transformResults (display, international) |
receives pre-ranked bound fares | yes | no | yes — policy winner preferred |
EconomyFlightSearchService::searchDomesticFlight (display, domestic) |
no | no | no | no |
BusinessFlightSearchService::transformResults (display) |
yes, plus the product’s result filters | yes | no | yes — best-ranked (the cheapest twin) |
Checkout live winner binding
Section titled “Checkout live winner binding”EconomyFlightSearchService::rankDisplayFares is the sole authority on
which economy live-search fare can be marked “Seleccionado”. The same
ranked list is then handed to bindFirstDisplayFare, which materializes
the first bindable fare as a DynamicFlightCache row and rebinds the
offer before the response is rendered. This keeps checkout display,
offer_flights, offer_flight_bindings, and admin in sync.
Before a fare can be ranked or bound it must pass, in order:
- Arrival cutoff (applied upstream when building
$arrivalFiltered). FlightRankingPolicy::passesMaxLayoverFare, thenpassesRankingMaxLayoverFare(the product’s shorter max layover, when set).FlightRankingPolicy::passesMinReturnDepartureFare.FlightRankingPolicy::passesMaxStopsFare($fare, $maxStops).FlightRankingPolicy::passesDepartureWindowFare($fare, ...$offer->outboundDepartureWindow()).FlightRankingPolicy::passesNightsAtDestinationFare($fare, $targetNights)— whentargetNightscan be derived. The target is sourced via the private helpertargetNightsForOffer(Offer $offer): ?int, which readsFlightRankingPolicy::targetNightsAtDestinationForRoute()off the bound cache row’s route. Returnsnull(filter skipped) when the offer has nocache-sourced internationalOfferFlightor the route can’t yield a target.
After filtering, candidates are ordered by the shared policy and pass
through the /search-only baggage chain: native bag → in-response
fare-family sibling. Candidates that cannot resolve a checked bag on both
international legs under an active baggage policy are dropped.
The filtered set is then sorted by the offer’s ranking (sortFareResults), visually deduped,
trimmed to the stop-count display frontier, and capped to the checkout
display limit.
bindFirstDisplayFare iterates the already-ranked display list. The first
fare that can be found or materialized through
EconomyFlightCacheUpdateService::findOrCreateCacheRowForFare is rebound
with OfferFlightUpgradeService::upgradeIfBetter(..., bypassPriceGuard: true) and then pinned as the first rendered card. If no ranked fare can
be represented by a cache row, the live response returns no selected fare
instead of rendering a card admin cannot reconcile.
Bound-flight signature
Section titled “Bound-flight signature”App\Services\Flights\Domain\OfferFlightSignature exposes the canonical
identity used by the FE reconciler to know which option the backend is
currently bound to:
fromCacheSegments(Collection $segments): string— per-legOPERATOR+FLIGHTNUMBER(joined by+for connections) from cache segments.fromFlightSegments(Collection $segments): string— same shape, built from AerticketFlightSegmentDTOs. Used by the live-search response renderer to emit asignatureon every leg option.compose(string $outbound, ?string $inbound): string— composes the round-trip option signature asoutbound|inbound(or justoutboundfor one-way).forBoundOffer(Offer $offer): string— returns the canonicaloutbound|inboundsignature of the offer’s currently-bound internationalOfferFlight, built from the cache row’s primary itinerary per leg (not positional index 1). Returns''when the offer has no resolvable international binding (draft, manual flight, missing cache row).
This identity is what the checkout response surfaces as
bound_flight_signature (root) and per-option signature — see
Checkout API → GET /flights.
Display Selection
Section titled “Display Selection”Checkout display does not preserve a stale bound fare as “Seleccionado”.
The selected card is the first live option after hard filters, baggage
resolution, sorting, visual deduplication, and the top-10 cap. Economy
display uses the same /search-only baggage rule as matched fare resolution
and live-winner binding: native bag or in-response siblings only. If the old bound fare violates max stops or
any other hard rule, it is removed from the displayed list instead of
being pinned.
Visual deduplication (display side)
Section titled “Visual deduplication (display side)”The international checkout live-search collapses fares that the customer
would see as identical cards into a single option. Two fares share the
visual signature when, for every segment of both the outbound and
inbound primary itinerary, they share the same departure / arrival
airports and the same wallclock departure / arrival times. The key is
built from the segments actually rendered (the primary itinerary per
leg picked by FlightRankingPolicy::primaryItineraryIndexForLeg).
This collapses:
- Codeshares — e.g. an Iberia-ticketed row and a Qatar-ticketed row sitting on the same operating flight, which previously rendered as two indistinguishable cards.
- Fare-family clones — Aerticket sometimes publishes the same physical flight under drifted flight numbers across families (BASIC / STANDARD / FLEX), which the bare-flight-number dedup let through.
- Genuine accidental twins — two fares that publish the same journey at the same wallclock on the same airports.
Within a dedup group the best-ranked fare under the offer’s ranking survives. Visual twins share duration, stops and arrival, so under every sort order that is the cheapest twin. Dedup runs before the display cap, so the cap returns distinct visual flights instead of being thinned later.
- Business additionally keeps any group member carrying an outbound
sibling itinerary none of the kept fares has
(
keepOutboundSiblingCarriers), so a departure that only exists in a pricier twin can still be shown after card expansion.
Where this rule does not apply
Section titled “Where this rule does not apply”- Domestic legs at checkout.
searchDomesticFlight→findDomesticFareMatchingLegsignature-matches the offer’s locked leg and never deduplicates. The locked leg is extracted from the cache by its bounditinerary_index_outbound(the in-window itinerary chosen byAutoOfferGeneratorService::domesticItineraryFor(), not necessarily the row’s primary), not byleg_sequencealone — a route’s multiple cached direct alternatives each live under their ownitinerary_index(see Dynamic Flight Cache — Segments Table), so pinning the index keeps the extracted leg a single flight (correctflight_numbers/stopover_airports/arrival_time) instead of welding all alternatives into a fake multi-stop connection. A genuine connection (multiplesegment_numberunder oneitinerary_index) is preserved. The per-legflight_signatureis built from those same pinned segments, so it identifies one flight and live re-pricing can actually match it. Past domestic-result-shrinkage incidents cannot recur from this change. - Offer generation / populator.
DynamicFlightCachePopulatorServicekeeps positions 1–5 of distinct fare-families on purpose so checkout’s bag-resolution chain has in-response siblings when Aerticket publishes bag and no-bag fare families together. - Booking-time matcher.
CheckoutFlightBookingServiceresolves identity via storedfare_idthen signature fallback — visual dedup preserves the matched fare so this path is unchanged.
Source: EconomyFlightSearchService::getFareKey,
EconomyFlightSearchService::pickGroupWinner,
BusinessFlightSearchService::getFareKey.
Business on domestic legs
Section titled “Business on domestic legs”A business upgrade covers an in-destination leg only when that exact flight
has a fare that can actually be sold. DomesticBusinessLegResolver runs a
live one-way BUSINESS search per domestic leg and accepts a fare only if all
three conditions hold:
- GDS content. The channel arrives per fare as
contentSource('GDS'|'nonGDS', the latter covering NDC + LCC). Non-GDS fares are cheaper and plentiful but cannot be ticketed through the flows this app books with, so quoting one would sell a seat that can never be issued. A fare whose channel was not reported counts as unsellable —FareResult::isExplicitlyGds()exists becausecontentSourcedefaults an absent value to'GDS', which is correct for gating ancillary calls but would read “unknown” as “safe” on the sale side. See Content Source Gating. - Business cabin. A business-cabin request can still return economy
fares, and an absent
cabinClassnormalizes to ECONOMY, so anything not explicitly business is refused. - Same physical flight. The fare’s leg signature must match the cached leg’s, checked across every itinerary of the fare (Aerticket re-orders and drops itineraries between requests). Paying for business must not move the customer to another departure on a day whose transfers and guides are already booked around the current one. Business on a different flight of the same day is therefore left unsold on purpose.
Legs with no qualifying fare keep their cached economy price, so a trip can
mix cabins. Every failure path — search error, empty response, no signature
to match — falls back the same way, which is the behaviour that shipped
before domestic legs were quoted in business at all. Per-reason counts are
logged (after_gds_only, after_business_cabin, after_same_flight) so
“no business on this leg” can be told apart from a bug.
At ticketing, a leg stored as BUSINESS binds GDS content only
(CheckoutFlightBookingService::findMatchingOneWayFare($gdsOnly: true));
an NDC fare for the same flight numbers would come back cheaper, win the
closest-to-expected-price match, and produce an unticketable PNR. Economy
legs keep the long-standing permissive matching.
Note that flight_type: domestic means “intermediate leg of the trip”, not
“national flight” — an HND-CCU hop (Tokyo→Kolkata via Singapore, 17h30) is a
domestic leg by this definition, which is why assumptions about short hops do
not hold here.
Source: backend/app/Services/Checkout/DomesticBusinessLegResolver.php,
backend/app/Services/Flights/Domain/FlightSearchFilterPipeline.php.
Business on mixed-cabin routes
Section titled “Business on mixed-cabin routes”The same principle — business where it is sellable, the next best cabin where it is not — has to hold for segments that sit inside the international fare, not only for legs searched separately.
cabinClassList is a whitelist Aerticket applies to every segment: a fare
comes back only if each of its segments is flown in one of the listed cabins.
Asking for BUSINESS alone therefore returns zero fares the moment any
segment lacks a business cabin. That is not a rare edge: it happens whenever an
in-destination airport is the origin of the international return — a PEM→MAD
leg is flown PEM→LIM→MAD, and the Peruvian feeder is domestic equipment with
no business cabin. One 1h40 hop emptied the business tab on a route whose
long-haul portion sells business freely (198 fares when MAD↔LIM is searched
on its own), and the agent had nothing to quote.
Premium is the cabin that unlocks it, not economy. Measured on that route:
cabinClassList |
Fares | What comes back |
|---|---|---|
[BUSINESS] |
0 | the feeder cannot be business |
[BUSINESS, ECONOMY] |
214 | feeder economy — and it drags the return long-haul to economy with it |
[BUSINESS, ECONOMY_PREMIUM] |
324 | feeder premium, business on both long-hauls |
[ECONOMY_PREMIUM] |
0 | the long-hauls have no premium |
The last row is the one that proves the whitelist reading: premium alone fails because the long-hauls cannot satisfy it.
Economy is deliberately absent from the list. Adding it floods the response with all-economy itineraries and the fares worth selling drop from 212 to 52 — and premium on a short feeder is a product the customer recognises as an upgrade, while economy on a 17h return long-haul is not.
The cabin list is chosen per product.
products_by_market.business_search_cabin_mode (App\Enums\BusinessSearchCabinMode,
NULL = default) decides it, and BusinessFlightSearchService::cabinModeFor() reads
it for both the checkout display search and the post-payment booking re-search,
so the two requests stay identical and the fare the customer picked can still be
found by id.
- Default (
[BUSINESS, ECONOMY_PREMIUM]) — everything above. Every catalogue product stays here. - Business only (
[BUSINESS]) — for hand-tuned quote products (cotizaciones) on routes where every segment sells business.
Business-only exists because on those routes the premium allowance costs fares. Aerticket caps the result set per airline and fills it cheapest-first, so the cheaper mixed fares (business out, premium economy back) take the quota and push the all-business fares — and the alternative routings bundled in them — out of the response. The symptom is a fare the operator can see in the Aerticket cabinet that never reaches the business tab, typically because the only sibling routing that did come back fails a display gate such as the max-layover cap. Whether it happens depends on what the supplier returns on a given search, which is why the remedy is an opt-in per product rather than a change to the default list.
It is the Peru case in reverse, so it is not a catalogue setting: business-only
returns nothing at all on a route whose feeder has no business cabin. The field
lives in the Flight Search Filters section, last on the product edit page,
under a red cotizaciones-only warning that covers every field in that section
(see Products by Market). The ?diagnostic=1
payload reports the mode in force as cabin_mode (default | business_only).
BusinessFlightSearchService then does the selecting itself:
- A leg counts as business when every segment in it is business.
- A fare is offered when at least one leg qualifies. On the Peru route every accepted fare carries business on both long-hauls, with only the feeder in premium.
- Fares with no fully-business leg are dropped. This is the business tab.
- A business hop feeding a lesser long-haul does not qualify. Accepting it would charge an upgrade for the cheap half of the trip.
A segment that declares no cabin of its own inherits the fare’s, because
FlightSegment defaults an absent cabin to economy: safe for display, but as
a selection rule it would read a supplier that reports cabin only at fare
level as an all-economy itinerary and empty the tab all over again.
Two consequences worth knowing:
- Labelling is part of the fix. The response’s top-level
cabinClassnames the tab, so every leg option carries its owncabinClassand the checkout persists it per leg (flight_selection.outbound.cabin_class). The frontend decides the tag withresolveLegCabin()/legCabinLabel()— one pair of helpers, because the rule previously lived inline in the navbar, the summary and the selector card. The card is the surface that matters most: it used to print the tab’s cabin, so a half-upgraded fare read as plain “Business” while the customer paid an upgrade for a leg flown in premium. It now readsBusiness ida · Premium vueltawhenever the two legs disagree. A leg mixing cabins is named by its floor, so a return flown premium on the feeder and business on the long-haul reads as premium — never promising the better cabin on the strength of one segment, and never calling premium “economy” either. The customer-facing wording for that case is a product decision still in flight; the mechanism is not. - The diagnostic has its own reason for them. The default mode’s second
cabin puts fares
with no business leg in the raw set the
?diagnostic=1panel reports on, and they were being blamed on the layover cap or the ranking — the panel exists precisely so nobody chases the wrong filter. They now readnot_business(“Sin business en ningún trayecto”). On the Peru route that is 182 of 324 fares, so the mislabelling was the bulk of the table. - The booking path is filtered too.
searchBusinessFlightsRaw()strips economy-only fares before returning. The post-payment matcher falls back to matching the customer’s flights by physical signature when the stored fare id has aged out, and an economy fare flies the very same aircraft — leaving it in the set would let someone who paid for business be booked, and then ticketed, in economy.
Cabins do vary within a single leg — the return above is premium then business — which is why the whitelist reading matters and why an earlier “one cabin per leg” assumption was wrong. What remains outside this fix is business on the feeder itself: that hop has no business cabin at all, and the only way to sell one would be to split the return into a separate domestic ticket plus the long-haul — normally dearer, and with no connection protection if the feeder runs late.
Source: backend/app/Services/Checkout/BusinessFlightSearchService.php,
backend/app/Services/SettingsService.php,
frontend/src/features/checkout/utils/flightFormatting.ts.
Economy with premium economy (cotizaciones)
Section titled “Economy with premium economy (cotizaciones)”The whitelist cuts the other way too. Some long-haul returns sell no economy
at all on a given date: the fare flies out in economy and back in premium
economy. Asking for [ECONOMY] drops it at the supplier, and the business list
([BUSINESS, ECONOMY_PREMIUM]) rejects it over the economy outbound — so the
fare belongs to neither tab, is never cached, and cannot even appear in the
?diagnostic=1 table as excluded, because it never arrived. The operator sees
it in the Aerticket cabinet and Arkana simply has no place to put it.
products_by_market.economy_search_cabin_mode
(App\Enums\EconomySearchCabinMode, NULL = default) opts a product into
[ECONOMY, ECONOMY_PREMIUM]. EconomyFlightSearchService::cabinModeFor() reads
it for both the checkout display search and the post-payment booking
re-search, for the same reason the business side does: the booking flow looks
the displayed fare up by id, so the two requests must be identical.
Measured on BCN→LIM + CUZ→BCN 2026-11-25, through the full pipeline:
cabinClassList |
Fares | Displayed | Mixed options |
|---|---|---|---|
[ECONOMY] |
223 | 42 | 0 |
[ECONOMY, ECONOMY_PREMIUM] |
169 | 40 | 7 |
The extra cabin is not free: the per-airline quota is filled cheapest-first, so all-economy fares get pushed out to make room (42 → 33 of them here). That is the trade an operator accepts deliberately on a quote, and the reason this is an opt-in rather than a new default.
Two scope rules keep the blast radius small:
- International only. Domestic legs keep
[ECONOMY]on both the display search and the booking re-search — they are searched and booked one at a time, so no single fare can mix cabins across them. - Read-only while active. The search refreshes no cache-row price, binds
the offer to no live fare and reprices nothing — so no
dynamic_flight_cachesrow is created or rewritten from a premium-bearing response. An offer’s international flight is a pointer into that table, and its rows are keyed by route shape (route_hash), shared by every product with that shape, so such a write would reach other products’ offer generation.buildInternationalSelection()skips the refresh and the binding ($mayMutate), andEconomyFlightCacheUpdateServiceguards itself as well —updateCacheAndOfferPrice()andrefreshSignatureMatchedRows()return early and logSkipping cache/offer writes— so no caller can bypass it. The cost is that the product stops self-healing: its offer keeps the generator’s price, and the Recalculate offer / Recalculate selected admin actions andoffers:verify-flightsread without repairing anything. - The checkout session prices itself. Because the offer is never repriced,
its stored price would go stale under the live cards — and the cards are
deltas against the live winner, so
advertised base + deltawould be off by the drift. The session therefore takes the same path a 3+ pax session does:CheckoutSessionService::start()setsreprices_from_live_search(true for non-standard pax or a read-only economy product), and every pricing gate that used to readis_non_standard_paxreads that flag instead. The live search re-quotes the session base from the all-legs live total (repriceNonStandardPaxFromLiveSearch()),get()stops re-pulling it from the offer, and selection re-quotes it from the chosen fare’sfare_total_price. The client commits to the live price; the offer and the shared cache stay exactly as they were. See POST /checkout/{offerId}/search-economy-flights.
The field sits next to the business one in the Flight Search Filters
section under the same red cotizaciones-only warning, and ?diagnostic=1
reports the mode as cabin_mode (default | with_premium_economy).
These fares are still labelled “Turista” everywhere. Unlike the business
tab, an economy leg option carries no leg-level cabinClass, so
resolveLegCabin() falls back to the tab’s cabin on the selector card, the
navbar and the summary; CheckoutSessionService::buildEconomyLegs() likewise
persists cabin_class: 'ECONOMY' on every leg, so the booking records economy
too. Only segments[].cabinClass carries the truth, and nothing renders it.
Deliberately deferred — the per-leg labelling the business tab already has is
what would fix it.
Source: backend/app/Services/Checkout/EconomyFlightSearchService.php,
backend/app/Services/Checkout/EconomyFlightCacheUpdateService.php,
backend/app/Enums/EconomySearchCabinMode.php.
Rerank console command
Section titled “Rerank console command”php artisan flights:rerank-cache rewrites fare_position on every
completed cache row using the current policy: the global international
ranking on international routes, the fixed duration-first
comparator order on domestic ones, with arrival-cutoff violators last. It reports how aggressively the
rules trimmed the catalogue:
- Excluded (>8h layover) — rows that failed the global max layover (the label is fixed at 8h whatever the setting).
- Pareto-dominated (skipped from ranking) — rows pruned because
another row in the same
(route, departure_date, return_date, CUG)bucket was both faster and cheaper.
Both are informational; the rows stay in the DB, they just don’t carry a
fare_position.
Source: backend/app/Console/Commands/RerankDynamicFlightCaches.php
Related Documentation
Section titled “Related Documentation”- Airport Search - Full-text search implementation
- AerTicket Integration - Flight search API service
- Dynamic Flight Cache - Cache backend and baggage resolution flow
- Offer Flight Upgrade - Audited binding swaps that consume this policy