# M0b — Meilisearch production cutover

BayMove property search uses **Laravel Scout** with a runtime switch between the **Meilisearch** engine and the **collection** engine (Eloquent + in-memory sort/filter). Only the `Listing` model is indexed.

This runbook is for Mike/ops and Andy’s M0b window (~28 Sep–6 Oct). It does not depend on PR #25 (OrderMarket price paid).

## Current stack (inventory)

| Layer | Role |
| --- | --- |
| `SCOUT_DRIVER` | `meilisearch` → Scout + Meili; anything else → collection/DB path in `SearchListings` |
| `Listing` + `Searchable` | Index sync on create/update/delete when `shouldBeSearchable()` (live, not soft-deleted) |
| `config/scout.php` | Meili host/key; `listings` index filterable/sortable attributes |
| `SearchListings` | Meili: filters via `ListingSearchFilter`, geo sort/radius, Scout pagination + Eloquent `with()` |
| `compose.yaml` | Local Meili service on port 7700 (Sail) |
| Queues | `SCOUT_QUEUE` (default `false`) — when `true`, Scout sync jobs use `QUEUE_CONNECTION` |
| Tests | `phpunit.xml` sets `SCOUT_DRIVER=collection` (no Meili in CI) |

**Not in repo:** production Meili host, API keys, Terraform/Cloud resource definitions, or Laravel Cloud env bindings — those are ops decisions.

### Index name

Scout index UID: `{SCOUT_PREFIX}listings` (model table name `listings`). Use `SCOUT_PREFIX` to isolate staging vs production on a shared Meili instance.

### Indexed document shape

See `Listing::toSearchableArray()` — filter/sort fields: `status`, `transaction_type`, `property_type`, `price`, `bedrooms`, `area_id`, `postcode_sector_id`, `postcode_district`, `is_premium`, `published_at`, `_geo`.

### Automatic sync

Publishing, expiring, or editing listings updates the index via Scout model events (sync or queued per `SCOUT_QUEUE`). `listings:expire-due` (hourly schedule) expires listings and removes them from the index when status leaves `live`.

There is **no dual-write**: cutover is flipping `SCOUT_DRIVER` and ensuring the index is populated.

## Production prerequisites (Mike/ops)

1. **Meilisearch instance** — reachable from app workers and web (HTTPS URL, TLS cert valid for PHP HTTP client).
2. **Secrets** (Laravel Cloud / env store, not committed):
   - `MEILISEARCH_HOST` — e.g. `https://meilisearch.example.com`
   - `MEILISEARCH_KEY` — master or dedicated search API key with index read/write
3. **Application env**
   - `SCOUT_DRIVER=meilisearch`
   - `SCOUT_PREFIX` — set per environment if sharing one Meili cluster
   - `SCOUT_QUEUE=true` — recommended in production so HTTP requests are not blocked on index writes; requires **queue workers** running
   - `SCOUT_AFTER_COMMIT=true` — recommended so rolled-back transactions do not sync
   - `QUEUE_CONNECTION` — must match a worker-backed connection (e.g. `redis`, `database`)
4. **Network** — outbound from app to Meili; no inbound from the public internet to Meili unless intentionally exposed (prefer private network).

## Cutover steps (ordered)

### Before the window

1. Provision Meili and store `MEILISEARCH_HOST` / `MEILISEARCH_KEY` in staging, then production secret stores.
2. On **staging**, set `SCOUT_DRIVER=meilisearch` and deploy.
3. Run index prep (one-off or release command):

   ```bash
   php artisan search:reindex-listings
   ```

   Optional full rebuild:

   ```bash
   php artisan search:reindex-listings --flush
   ```

4. Verify:

   ```bash
   php artisan search:meilisearch-health
   ```

5. Smoke-test property search: filters, sort (price, recommended, distance), radius, area/sector/district locations, empty results, pagination.
6. Compare result counts vs `SCOUT_DRIVER=collection` on the same DB snapshot (spot-check).

### Go-live (production)

1. Deploy build with Meili env vars present but **`SCOUT_DRIVER=collection`** if you want a final DB-only baseline (optional).
2. Run `search:reindex-listings` against **production** Meili (from a one-off console / Cloud command) so the index is warm before traffic uses Meili.
3. Flip **`SCOUT_DRIVER=meilisearch`** and redeploy or reload config (no code change required).
4. Run `search:meilisearch-health` and manual search smoke tests.
5. Monitor app logs, queue failures, and Meili latency/error rates.

### Rollback

1. Set `SCOUT_DRIVER=collection` and redeploy — search immediately uses the Eloquent path in `SearchListings` (no Meili calls).
2. Meili index can remain; no data migration required.
3. Investigate Meili/app issues before re-attempting cutover.

## Gaps and blockers

| Item | Owner | Notes |
| --- | --- | --- |
| Prod/stg Meili hosting model | Mike/ops | Not defined in repo (Cloud VM, Meilisearch Cloud, sidecar, etc.) |
| `MEILISEARCH_*` in Laravel Cloud | Mike/ops | Must be set before cutover |
| Queue workers for `SCOUT_QUEUE=true` | Mike/ops | Without workers, index updates lag or run synchronously if queue is `sync` |
| `/up` health | — | Only default Laravel health; Meili is **not** wired into `/up` — use `search:meilisearch-health` or external monitoring |
| PR #25 | — | Unrelated to Meili cutover; do not block M0b on it |

## Artisan reference

| Command | Purpose |
| --- | --- |
| `search:reindex-listings` | `scout:sync-index-settings` + `scout:import` for `Listing` |
| `search:reindex-listings --flush` | Also `scout:flush` before import |
| `search:meilisearch-health` | `GET /health` + listings index stats |
| `scout:import "App\Models\Listing"` | Low-level import (prefer wrapper above) |
| `scout:sync-index-settings` | Push `config/scout.php` Meili settings |

## Local development (Sail)

```bash
./vendor/bin/sail up -d
./vendor/bin/sail artisan search:reindex-listings
./vendor/bin/sail artisan search:meilisearch-health
```

Ensure `.env` matches `.env.example` for `SCOUT_DRIVER` and `MEILISEARCH_HOST=http://meilisearch:7700`.
