# 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.