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.
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 |
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 Volare 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.
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 (fare_position), the auto-offer
generator, the upgrade service, the flights:rerank-dynamic-caches
console command, and the live-search at checkout. All paths apply the
same filter + comparator rules so the cached “position 1”, the
generator’s pick, and the live-search winner stay aligned. Pareto pruning
is still applied only on the back-office/frontier paths that explicitly
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 8 hours
(
MAX_LAYOVER_MINUTES = 480). Direct flights pass the layover check trivially. The filters run before sorting. - Stop-tier prune — 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.
- 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 liveFareResultDTOs it sumssegment.duration + layover gapsdirectly — timezone-safe since layover gaps are at the same airport. - Tiebreaks — price, then fewer stops, then earliest outbound arrival at destination.
- Prune (Pareto) —
pruneDominatedCacheFares/pruneDominatedFareResultsdrop 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
Where it’s applied
Section titled “Where it’s applied”| Caller | Filter | Order | Prune | Visual dedup |
|---|---|---|---|---|
DynamicFlightCachePopulatorService (assigns fare_position) | yes | yes | yes (full frontier) | no |
AutoOfferGeneratorService (picks the offer’s flight) | yes | yes | yes (full frontier) | 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 (full frontier) | 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 |
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.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 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.
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 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_idfast-path inCheckoutFlightBookingServicekeeps working). Otherwise the cheapest wins, since the upstreamcompareFareResultsort places it first when group members share duration (which visual twins do). - Business —
$group->first()always; no matched-fare concept here. Dedup runs beforetake(10)so the cap returns ten distinct visual flights instead of being thinned later.
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 primary itinerary), 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.
Rerank console command
Section titled “Rerank console command”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
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