Skip to content

Flight Search UI

Native FilamentPHP v5 implementation of flight search interface with interactive leg selection, collapsible panels, and component-based architecture.

URL: /admin/flight-searches/flights

Navigation: Admin Panel → Flight Searches → Flights (Aerticket)

  1. Select departure and arrival airports (searchable dropdowns)
  2. Choose dates and passenger counts
  3. Configure fare options and preferences
  4. Click “Search Flights”
  5. Browse results with interactive leg selection
  6. View fare details and book flights

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
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()
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'
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
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
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'])
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
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')

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)
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;
}
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)
ViewFareDetailsAction::make()
->rawFareData($this->rawFareData)

Features:

  • Complete fare breakdown
  • Passenger pricing
  • Baggage allowance
  • Booking action
UpsellAction::make()
->rawFareData($this->rawFareData)

Features:

  • Searches for upgraded fare options
  • Uses user’s selected leg choices
  • Replaces search results with upsell results
  • sm - 640px
  • md - 768px
  • lg - 1024px

Usage:

Split::make([/* ... */])->from('md') // Horizontal on medium+
Stack::make([/* ... */]) // Always vertical
  • Full-width fields on mobile
  • Grid collapses to single column
  • Collapsible panels for details
  • Touch-friendly input sizes
  • Limit to 10 results for dropdowns
  • Uses GIN index for sub-millisecond queries
  • See Airport Search documentation
  • Component-level cache prevents duplicate API requests
  • Cache key: fareId|itinerary1:itinerary2
  • Automatic invalidation on search state changes
public function clearResults(): void
{
$this->searchResults = collect();
$this->rawFareData = [];
$this->selectedLegs = [];
$this->fareAncillaryCache = [];
$this->hasSearched = false;
}
/admin/flight-searches/flights
# Access flight search page
# Test scenarios:
1. One-way search: BCN MAD, 1 adult, Economy
2. Round-trip search: LHR JFK, 2 adults + 1 child, Business
3. Multi-city search: BCN BKK CNX BCN, 2 adults
4. Multi-leg selection: Expand panel, click different options
5. Fare details: Click "View Details" action
6. Validation: Try >9 passengers
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);
});
  1. Check API credentials configured
  2. Verify AerTicket service accessible
  3. Validate airport codes
  4. Check date range (max 11 months)
Terminal window
php artisan aerticket:test-connection
php artisan pail --filter="Aerticket"
  1. Check Livewire scripts loaded
  2. Check browser console for errors
  3. Verify wire:click attribute present

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.

URL: /admin/settings/flight-search-filters

Source: backend/app/Filament/Pages/Settings/FlightSearchFiltersSettings.php, backend/app/Services/SettingsService.php

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

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. Sends needBaggage=true to 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 /search response.

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.

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.

Related:

FlightRankingPolicy is the single source of truth for “which flight is better” across the cache populator (fare_position), the auto-offer generator, the upgrade service, the flights:rerank-dynamic-caches console command, and the live-search at checkout. All paths share the same hard filters and comparator, so the cached “position 1”, the generator’s pick, and the live-search winner 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 is price-first after duration. 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):

  1. 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 8 hours (MAX_LAYOVER_MINUTES = 480). Direct flights pass the layover check trivially. The filters run before sorting.
  2. 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).
  3. Order — by 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 live FareResult DTOs it sums segment.duration + layover gaps directly — timezone-safe since layover gaps are at the same airport.
  4. Tiebreaks — price, then fewer stops, then earliest outbound arrival at destination.
  5. Prune (Pareto)pruneDominatedCacheFares / pruneDominatedFareResults drop any fare that is both longer and more expensive than another kept fare (with at least one strict inequality). Longer-but-cheaper fares stay. This subsumes the operator rule “a cheaper direct erases longer pricier stopovers” — no special case needed.

The two comparators (compareFlightCache for DB rows, compareFareResult for live DTOs) target different shapes but implement the same order.

Source: backend/app/Services/Offers/FlightRankingPolicy.php

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 yes yes (Pareto duration×price; 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-dynamic-caches 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 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::transformBusinessResults (display) yes yes no yes — cheapest

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:

  1. Arrival cutoff (applied upstream when building $arrivalFiltered).
  2. FlightRankingPolicy::passesMaxLayoverFare.
  3. FlightRankingPolicy::passesMinReturnDepartureFare.
  4. FlightRankingPolicy::passesMaxStopsFare($fare, $maxStops).
  5. FlightRankingPolicy::passesDepartureWindowFare($fare, ...$offer->outboundDepartureWindow()).
  6. FlightRankingPolicy::passesNightsAtDestinationFare($fare, $targetNights) — when targetNights can be derived. The target is sourced via the private helper targetNightsForOffer(Offer $offer): ?int, which reads FlightRankingPolicy::targetNightsAtDestinationForRoute() off the bound cache row’s route. Returns null (filter skipped) when the offer has no cache-sourced international OfferFlight or 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 compareFareResult, 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.

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-leg OPERATOR+FLIGHTNUMBER (joined by + for connections) from cache segments.
  • fromFlightSegments(Collection $segments): string — same shape, built from Aerticket FlightSegment DTOs. Used by the live-search response renderer to emit a signature on every leg option.
  • compose(string $outbound, ?string $inbound): string — composes the round-trip option signature as outbound|inbound (or just outbound for one-way).
  • forBoundOffer(Offer $offer): string — returns the canonical outbound|inbound signature of the offer’s currently-bound international OfferFlight, 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.

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.

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 survivor is picked by pickGroupWinner:

  • Economy — the offer’s locked-in fare wins if it’s in the group (booking identity preserved so the booking-time fare_id fast-path in CheckoutFlightBookingService keeps working). Otherwise the cheapest wins, since the upstream compareFareResult sort places it first when group members share duration (which visual twins do).
  • Business$group->first() always; no matched-fare concept here. Dedup runs before take(10) so the cap returns ten distinct visual flights instead of being thinned later.
  • Domestic legs at checkout. searchDomesticFlightfindDomesticFareMatchingLeg signature-matches the offer’s locked leg and never deduplicates. The locked leg is extracted from the cache by its bound itinerary_index_outbound (the primary itinerary), not by leg_sequence alone — a route’s multiple cached direct alternatives each live under their own itinerary_index (see Dynamic Flight Cache — Segments Table), so pinning the index keeps the extracted leg a single flight (correct flight_numbers / stopover_airports / arrival_time) instead of welding all alternatives into a fake multi-stop connection. A genuine connection (multiple segment_number under one itinerary_index) is preserved. The per-leg flight_signature is 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. DynamicFlightCachePopulatorService keeps 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. CheckoutFlightBookingService resolves identity via stored fare_id then signature fallback — visual dedup preserves the matched fare so this path is unchanged.

Source: EconomyFlightSearchService::getFareKey, EconomyFlightSearchService::pickGroupWinner, BusinessFlightSearchService::getFareKey.

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 because contentSource defaults 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 cabinClass normalizes 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.

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 8h 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 cabinClass names the tab, so every leg option carries its own cabinClass and the checkout persists it per leg (flight_selection.outbound.cabin_class). The frontend decides the tag with resolveLegCabin() / 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 reads Business ida · Premium vuelta whenever 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=1 panel 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 read not_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.

php artisan flights:rerank-dynamic-caches rewrites fare_position on every completed cache row using the current policy. It reports two new metrics so operators can see how aggressively the new rules trimmed the catalogue:

  • Excluded (>8h layover) — rows that failed the filter.
  • 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