Hotel Discovery and Search Audit — 2026-08-28

📄 General
← Back to Documentation
# Hotel Discovery and Search Audit — 2026-08-28 ## Scope Read-only review of the live route `/locations?type=rentals&category=hotel`, its visible navigation/category cards, and the corresponding Laravel controller, models, navigation composer and Blade filter code. No booking, form submission or data change was made. ## Verdict **Not ready to be the production hotel-search source of truth.** The live URL currently displays one location, matching its menu count by coincidence in the small current inventory. The server-side venue query does not apply the selected category at all, most visible filters have a different URL contract from the controller, and the public scope is weaker than the model's own public-display requirements. ## Verified live behavior - The hotel menu badge showed `1`; the hotel route showed `1` location/result. - The route rendered a location-detail link and preserved its query string. - Adding the UI's own flat `min_price=999999` parameter still returned the same location and `1` result. The visible price control therefore does not filter. - The hotel category card displayed a different availability figure (`24+`) from the menu/result count (`1`). Counters/marketing availability are not one shared metric. - The page exposes destination/date, adults/children/rooms, budget, property, amenity, rating/review and sort controls, but they do not currently form one verified search contract. ## P0 — Correctness and publication control 1. **Selected category is not applied to the venue result query.** `WebController::locations()` passes only the booking type into `buildVenueSearchQuery()`. That method never reads or applies the request's `category`. `type=rentals&category=hotel` can therefore return every matching rental venue as inventory grows. The separate location query does apply a category filter, so location cards and venue data can disagree. 2. **Category and booking-type deactivation can be bypassed with a direct URL.** `applyCategoryFilter()` falls back to venue-text matching when an active `Category` record is absent; this includes an inactive category. When no active category exists for a booking type, `applyFlexibleBookingTypeFilter()` returns without applying any booking-type predicate. Turning off a category hides it in navigation, but does not reliably prevent public discovery via `?category=...`; turning off all categories of a type can broaden results. 3. **Public visibility is not one shared scope.** Search starts with `Venue::withoutGlobalScopes()->where('is_active', 1)`. It bypasses company/workspace scopes and does not use `Venue::publiclyDisplayable()`, despite that model scope requiring verified, certificate-valid, non-suspended venues. Search also does not require an active parent `Place`, an active `Location` for the venue result query, or an active/bookable hotel room/unit. An active venue with no available active room can be advertised as hotel inventory. 4. **No configured super-admin category-control surface was found.** The `categories.is_active` database flag exists and drives the navigation/API, but there is no Category Filament resource or equivalent authenticated management route in the inspected code. The flag is therefore not a complete, auditable super-admin on/off control. ## P1 — Broken filter contract The Blade filter script emits flat keys, while `buildVenueSearchQuery()` reads different nested keys. Examples: | Visible control emits | Server reads | Result | | --- | --- | --- | | `ss` | `q.location` / `location` / `search` | Destination field is ignored. | | `min_price`, `max_price` | `q.price.min`, `q.price.max` | Price filter is ignored. | | `property_type` | `q.property_type` | Property-type filter is ignored. | | `group_adults`, `group_children`, `no_rooms` | `q.guests` only | Guest/children/room controls are ignored. | | `amenities[]` | `q.amenities.<amenity>` | Amenity controls are ignored. | | `min_rating`, `min_reviews` | no implementation | Rating/review controls are ignored. | | `pickup`, `dropoff` for rentals | date values are only displayed | No availability overlap check is performed. | Additionally, the current amenity query checks `name`, `slug` and `description` on `venue_facilities`, whereas the model stores the actual facility through its `facility()` relationship. Once its parameter mismatch is corrected, that query must use the related active facility rather than arbitrary pivot columns. ## P1 — Counters, sorting and taxonomy 1. **Counters use three incompatible definitions.** Navigation counts `Category::venues()` via an exact `venues.category = categories.name` match; the search/location queries use independent keyword/description matching; and category cards use a third helper. They cannot be expected to agree. The navigation composer also creates one count query per category. 2. **Category cards use hard-coded categories and fallback price/feature copy.** They can advertise empty categories and default price ranges as if live inventory exists. Hotel card availability must use the same eligible, date-aware inventory count as results. 3. **Sorting is not wired.** The page has duplicate `id="sort-select"` elements, no inspected change handler, and its values (`price`, `rating`, `distance`) do not match the controller's supported values (`price_low`, `price_high`, `rating`, `distance`, etc.). 4. **Text-based category matching is unsafe and non-index-friendly.** The controller has many `LIKE` predicates over category, name and description. This causes false positives/negatives, localisation dependence and poor scale. It must be replaced by one canonical category identifier/mapping with a reviewed legacy-data migration. 5. **Displayed result count is page count, not total.** The view uses `$locations->count()` instead of paginator total, so a paginated search can state a misleading result count. ## Required search model For hotel search, use a single typed `SearchCriteria`/request validator and one shared `PublicSearchScope`. At minimum it must enforce: - active, verified, certificate-valid venue; active parent place/location; active, bookable room/unit; relevant company/workspace publication rules; - active canonical category and booking type, with direct URLs to disabled or unknown categories returning a localized 404 or safe empty result; - destination (country/city/place/location), check-in/check-out, adults, children ages and rooms, with validation of chronological dates and occupancy; - room-level availability, maintenance/housekeeping eligibility, holds and booking-overlap checks for the selected stay dates; - genuine price/rate-plan currency and tax/fee representation, property type, amenities, verified review aggregates, cancellation/payment policies and appropriate sort choices; - one shared scope for cards, counters, map/list views, detail eligibility and booking revalidation. Counts must use this exact scope, with a clear distinction between properties, room units and date-available units. ## Recommended fix order 1. Define the canonical request schema and validate/normalise all filter inputs. 2. Implement the single public/active/category scope; remove deactivation fallbacks and `withoutGlobalScopes()` from public discovery unless an explicit, safe cross-tenant publication scope replaces it. 3. Make category apply consistently to location, venue, map, count and detail queries; migrate legacy category data to stable identifiers. 4. Implement hotel date/guest/room availability against active room units and rate plans; preserve criteria through location → place → venue → booking. 5. Replace all counters/card availability with batched queries using the shared scope; remove fabricated fallback metrics. 6. Add a super-admin category/booking-type publishing interface with audit log, confirmation and cache invalidation. 7. Wire sorting and every retained control, or remove the control until it has real semantics. ## Release gate - A disabled category/type is absent from navigation, API, direct URL, count, map, location, venue and booking flows. - Inactive/unverified/suspended venue, inactive place/location and inactive or unbookable room never appear in public results or counts. - Each filter demonstrably changes results using fixtures that distinguish it. - Hotel dates/occupancy change date-available room/property counts correctly and are revalidated at booking time. - Navigation count, category card count, result paginator total and map count use explicit compatible metrics and have regression tests. - Feature tests cover disabled categories/types, all scope boundaries, invalid criteria, hotel availability overlap, direct deep links, sorting and pagination; browser tests cover the visible query-string contract.