Skip to main content
Glama
README.md
# Restaurant Reservations — MCP Tool

A small Laravel application that exposes restaurant booking as an **MCP tool**, so a customer
(or an AI agent acting for them) can reserve a table. Every call answers three things:

- whether the requested date and time can be seated,
- the confirmed reservation (reference, date, time, party size),
- the nearest alternative times when the requested slot cannot take the party.

It is built to absorb many concurrent calls: Octane/Swoole keeps the framework booted in memory,
and the capacity check is a single atomic Redis operation, so the same seat can never be sold
twice.

**Measured: 1,584 req/s with zero errors (p95 140 ms), and exactly 40 of 40 seats sold when
12,001 requests fought over one slot.** Full method and numbers in
[How I tested performance and what the results were](#how-i-tested-performance-and-what-the-results-were).

> Want to check every claim in this README yourself? [VERIFY.md](VERIFY.md) is a copy-paste
> checklist: each requirement from the brief, the command that proves it, and what a pass looks
> like.

---

## Contents

The sections the brief asks for come first, then how to connect an AI agent over MCP, then the
constraints and bonus items, then deeper reference material.

**The brief, answered directly**

1. [How to start the project with Sail](#how-to-start-the-project-with-sail)
2. [How to call the MCP tool manually](#how-to-call-the-mcp-tool-manually)
3. [Setting up the MCP with an agent (Claude, Codex, and others)](#setting-up-the-mcp-with-an-agent-claude-codex-and-others)
4. [How I tested performance and what the results were](#how-i-tested-performance-and-what-the-results-were)
5. [Assumptions or tradeoffs](#assumptions-or-tradeoffs)

**How this submission maps to the brief**

6. [Constraints](#constraints)
7. [Bonus features](#bonus-features)

**Reference**

8. [How it works](#how-it-works)
9. [Tests](#tests)
10. [How this would scale](#how-this-would-scale)
11. [Configuration](#configuration)

---

## How to start the project with Sail

**Requirements:** Docker only (Docker Desktop on macOS/Windows). No local PHP, Composer or Node is
needed — everything runs inside the Sail containers.

```bash
# 1. Install PHP dependencies using a throwaway container (only needed once)
docker run --rm -v "$(pwd)":/var/www/html -w /var/www/html \
    laravelsail/php84-composer:latest composer install --ignore-platform-reqs

# 2. Create your environment file
cp .env.example .env

# 3. Start everything
./vendor/bin/sail up
```

> **On the one manual step.** `vendor/` is not committed, so step 1 installs dependencies once via a
> throwaway container — no local PHP or Composer required. Beyond that single command and copying
> `.env`, there is no manual setup: migrations and key generation happen automatically on boot, so
> adding environment variables is all that stands between a clone and a running server.

That is the whole setup. On boot the container:

- generates `APP_KEY` if the env file has none,
- waits for MySQL to accept connections,
- runs migrations,
- and starts Octane (Swoole).

So there is **no** separate `./vendor/bin/sail artisan migrate` step and no second terminal — a
single `sail up` gives you a working server. The boot sequence lives in
[docker/start-app.sh](docker/start-app.sh) if you want to read exactly what happens.

The MCP endpoint is then available at **`http://localhost/mcp/reservations`**. A quick smoke test
that lists the exposed tools:

```bash
curl -s -X POST http://localhost/mcp/reservations \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Common follow-up commands:

```bash
./vendor/bin/sail artisan test          # run the test suite
./vendor/bin/sail artisan reservations:verify   # audit Redis counters against MySQL
./vendor/bin/sail down                  # stop everything
./vendor/bin/sail down -v               # stop and wipe the MySQL/Redis volumes
```

> **Note on `.env` changes.** Octane holds the environment in memory between requests, so editing
> `.env` while the server is running has no effect until you restart it with
> `./vendor/bin/sail restart`.

---

## How to call the MCP tool manually

Two tools are exposed on the reservation server. Both are plain **JSON-RPC 2.0 over a single HTTP
POST** — no session handshake is required, which keeps `curl` examples simple and also makes the
endpoint straightforward to load test.

| Tool | Purpose |
| --- | --- |
| `create-reservation` | Book a table (or be told why it can't be booked, with alternatives). |
| `check-availability` | List every bookable start time on a date and the seats left in each. |

### `create-reservation`

```bash
curl -s -X POST http://localhost/mcp/reservations \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "create-reservation",
      "arguments": {
        "customer_name": "Ada Lovelace",
        "customer_email": "ada@example.com",
        "customer_phone": "+44 7700 900123",
        "party_size": 2,
        "date": "2026-09-20",
        "time": "19:00",
        "special_requests": "Window table if possible",
        "idempotency_key": "booking-attempt-001"
      }
    }
  }'
```

**Arguments**

| Field | Required | Rule |
| --- | --- | --- |
| `customer_name` | yes | string, 2–120 chars |
| `customer_email` | yes | valid email, ≤180 chars |
| `customer_phone` | no | string, ≤40 chars |
| `party_size` | yes | integer, 1–12 |
| `date` | yes | `YYYY-MM-DD` |
| `time` | yes | `HH:MM`, 24-hour, on the 30-minute grid |
| `special_requests` | no | string, ≤500 chars |
| `idempotency_key` | no | string, ≤64 chars; a repeat with the same key returns the original booking |

Input is validated **before** anything is booked; failures come back as an MCP tool error listing
exactly what was wrong.

**Confirmed response**

```json
{
  "status": "confirmed",
  "message": "Table confirmed for 2 on Sunday 20 September at 7:00 PM. 38 seat(s) left in that slot.",
  "reservation": {
    "reference": "RSV-LZEZUGIXZ8",
    "date": "2026-09-20",
    "time": "19:00",
    "ends_at": "20:30",
    "party_size": 2,
    "customer_name": "Ada Lovelace",
    "customer_email": "ada@example.com",
    "timezone": "UTC"
  },
  "alternatives": [],
  "already_booked": false
}
```

**Slot full** — the agent is handed times it can offer instead:

```json
{
  "status": "unavailable",
  "message": "7:00 PM on Sunday 20 September is fully booked for a party of 2.",
  "reservation": null,
  "alternatives": [
    { "date": "2026-09-20", "time": "19:30" },
    { "date": "2026-09-20", "time": "18:30" },
    { "date": "2026-09-20", "time": "20:00" }
  ],
  "already_booked": false
}
```

**Status values**

| Status | Meaning |
| --- | --- |
| `confirmed` | Table booked; `reservation` is populated. |
| `unavailable` | The slot exists but cannot seat this party right now. Offer `alternatives`. |
| `rejected` | Never bookable as asked — closed, in the past, off the 30-minute grid, or a party too large. `message` explains which. |

### `check-availability`

```bash
curl -s -X POST http://localhost/mcp/reservations \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check-availability","arguments":{"date":"2026-09-20","party_size":4}}}'
```

Returns each bookable start time on that date with the number of seats still free in it (filtered
to those that can seat the requested `party_size`).

### From a voice agent (Vapi / Retell)

A phone agent can book through `POST /webhooks/voice/reservation`, which maps the platform's
function-call payload onto the *same* `ReservationService` — so capacity, alternatives and
idempotency behave identically whether the guest typed or spoke. Set `VOICE_WEBHOOK_SECRET` first;
an empty secret disables the endpoint rather than leaving it open.

```bash
curl -s -X POST http://localhost/webhooks/voice/reservation \
  -H 'Content-Type: application/json' \
  -H 'X-Vapi-Secret: local-voice-secret' \
  -d '{
    "message": {
      "type": "tool-calls",
      "call": { "id": "call_123" },
      "toolCalls": [{
        "id": "toolcall_abc",
        "function": {
          "name": "create_reservation",
          "arguments": {
            "customer_name": "Phone Caller",
            "customer_phone": "+44 7700 900999",
            "party_size": 3,
            "date": "2026-09-20",
            "time": "21:00"
          }
        }
      }]
    }
  }'
```

```json
{
  "results": [{
    "toolCallId": "toolcall_abc",
    "result": "Table confirmed for 3 on Sunday 20 September at 9:00 PM. 37 seat(s) left in that slot. The reference is R S V B R U 8 R K E C 5 0."
  }],
  "response": "Table confirmed for 3 ...",
  "status": "confirmed",
  "reference": "RSV-BRU8RKEC50",
  "alternatives": []
}
```

Details that matter on a phone call:

- **Both platforms are accepted.** Vapi's `message.toolCalls[]` envelope (with `arguments` as an
  object *or* a JSON string) and Retell's flat `{name, args, call}` body both work, and the reply
  carries Vapi's `results` envelope alongside the top-level fields Retell reads.
- **Signed, or refused.** Vapi's shared-secret header (`X-Vapi-Secret`) and Retell's body HMAC
  (`X-Retell-Signature`) are both verified, with timing-safe comparisons.
- **Email is optional here** — a caller has a phone number. At least one contact method is
  required. The MCP tool still requires an email.
- **The tool-call id becomes the idempotency key**, so a replayed turn cannot book twice.
- **References are spelled out** (`R S V B R U 8…`) so text-to-speech reads characters rather than
  inventing a word.
- Validation failures come back as a spoken sentence with HTTP 422, so the agent can simply ask the
  caller again.

To test against the real platform locally, expose the app with a tunnel
(`cloudflared tunnel --url http://localhost`) and point the platform's function URL at it.

---

## Setting up the MCP with an agent (Claude, Codex, and others)

This is how an AI agent — Claude, Codex, or any MCP-compatible client — connects to the two
reservation tools and books on a guest's behalf.

Both tools are served two ways from `routes/ai.php`: over **HTTP** at
`http://localhost/mcp/reservations` (simplest for any client that speaks streamable HTTP) and over
**stdio** via `php artisan mcp:start reservations` (for clients that launch a local process). The
server runs *inside* the container, so start the stack before connecting any client:

```bash
./vendor/bin/sail up -d
```

> In the examples below, replace `/absolute/path/to/reservations` with the absolute path to your
> clone (e.g. the output of `pwd`).

### Claude Code (HTTP — recommended)

The least fiddly option, because it just points at the running HTTP endpoint:

```bash
claude mcp add --transport http reservations http://localhost/mcp/reservations
claude mcp list          # expect: reservations ... ✓ Connected
```

Then start `claude`, type `/mcp` to confirm the two tools loaded, and ask in plain English, e.g.
*"book a table for 2 tomorrow at 7pm for Ada, ada@example.com"*. Remove it later with
`claude mcp remove reservations`.

### Claude Desktop (stdio)

Edit Claude Desktop's config file — `~/Library/Application Support/Claude/claude_desktop_config.json`
on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows — and add a `reservations`
server inside `mcpServers` (keep any existing keys):

```json
{
  "mcpServers": {
    "reservations": {
      "command": "docker",
      "args": [
        "compose",
        "-f", "/absolute/path/to/reservations/compose.yaml",
        "exec", "-T",
        "laravel.test",
        "php", "artisan", "mcp:start", "reservations"
      ],
      "env": { "WWWGROUP": "1000", "WWWUSER": "501" }
    }
  }
}
```

Then **fully quit** Claude Desktop (Cmd+Q / Quit, not just close the window) and reopen it. The
`reservations` connector should report connected, with `create-reservation` and `check-availability`
available.

Why call `docker compose` directly instead of `./vendor/bin/sail`? A desktop client launches the
command from its *own* working directory, not the project, so a relative `./vendor/bin/sail` — and
Sail's implicit "find `compose.yaml` in the current directory" — fails with
`no configuration file provided: not found` followed by `Sail is not running`. Pinning the compose
file with `-f <absolute path>` makes the working directory irrelevant. `-T` disables the pseudo-TTY
that would otherwise corrupt the JSON-RPC stream, and the two `env` values silence Sail's
`WWWGROUP`/`WWWUSER` warnings. The containers must already be running (`sail up -d`) whenever the
client launches; after a `sail down` or reboot, bring them back up and relaunch the client.

### Codex CLI (stdio)

Codex reads `~/.codex/config.toml`. Add the server there:

```toml
[mcp_servers.reservations]
command = "docker"
args = [
  "compose",
  "-f", "/absolute/path/to/reservations/compose.yaml",
  "exec", "-T",
  "laravel.test",
  "php", "artisan", "mcp:start", "reservations",
]

[mcp_servers.reservations.env]
WWWGROUP = "1000"
WWWUSER = "501"
```

Or register it from the command line:

```bash
codex mcp add reservations -- \
  docker compose -f /absolute/path/to/reservations/compose.yaml \
  exec -T laravel.test php artisan mcp:start reservations
```

The same rules as Claude Desktop apply: containers up first, `-f <absolute>` so the working
directory does not matter, and `-T` to keep stdout clean. Recent Codex builds can also connect over
HTTP instead of stdio — point those at `http://localhost/mcp/reservations`.

### MCP Inspector

To poke at the tools interactively without any client, use the bundled inspector:

```bash
./vendor/bin/sail artisan mcp:inspector mcp/reservations
```

## How I tested performance and what the results were

### Method

Load was generated with **k6** running in Docker, attached to the app's own Compose network so the
numbers measure the application rather than Docker Desktop's port forwarding:

```bash
# Throughput: bookings spread across the calendar
docker run --rm -i --network test_sail -v "$PWD/load-test:/scripts" \
    -e BASE_URL=http://laravel.test grafana/k6 run /scripts/spread.js

# Contention: every request fights for the SAME slot
docker run --rm -i --network test_sail -v "$PWD/load-test:/scripts" \
    -e BASE_URL=http://laravel.test grafana/k6 run /scripts/hot-slot.js

# Then prove nothing was oversold
./vendor/bin/sail artisan reservations:verify
```

(`-e BASE_URL=http://host.docker.internal` without `--network` also works, via the published port.)

Between runs the state was reset with `sail artisan migrate:fresh --force` and
`sail exec redis redis-cli flushall`.

**Hardware:** MacBook, 11 CPUs available to Docker, 8 GB VM. Octane ran 11 Swoole workers.
[spread.js](load-test/spread.js) ramps 10 → 200 VUs over 70 s;
[hot-slot.js](load-test/hot-slot.js) holds a constant 400 requests/s for 30 s against a single
slot.

### Results

| Scenario | Requests | Throughput | Avg | p95 | Max | Errors / timeouts |
| --- | --- | --- | --- | --- | --- | --- |
| Spread, 40 seats/slot | 110,903 | **1,584 req/s** | 84 ms | 140 ms | 796 ms | **0** |
| Spread, capacity raised (every call writes a row) | 87,180 | **1,245 req/s** | 107 ms | 171 ms | 936 ms | **0** |
| Hot slot, 400 req/s at one slot | 12,001 | 400 req/s absorbed | 4.4 ms | 3–15 ms | 312 ms | **0** |
| *Baseline:* Sail's default `artisan serve` | 8,330 | 119 req/s | 1.13 s | 1.74 s | 2.07 s | 0 |

Reading the table:

- **Throughput.** ~111,000 tool calls in 70 seconds with no failed request and no timeout (two
  consecutive runs measured 1,584 and 1,559 req/s). The second row is the harsher version of the
  same test: capacity was raised so that *every* call booked a table, i.e. 87,180 rows written to
  MySQL, still with zero errors.
- **Correctness under contention.** 12,001 requests aimed at one 40-seat slot in 30 seconds sold
  **exactly 40 seats** (20 parties of two) — not 39, not 41. The other 11,981 were turned away
  cleanly, each with alternative times. `reservations:verify` confirmed it against MySQL:

  ```
  | Slot (UTC)       | Seats sold | Bookings | Capacity |
  | 2026-09-12 19:00 | 40         | 20       | 40       |
  No slot exceeds capacity.
  Redis counters match the database.
  ```

  Latency *drops* under contention because a full slot is answered by Redis alone — the rejection
  never reaches MySQL. Across six repeat runs the median stayed at ~3.3 ms while the p95 wandered
  between 3 ms and 15 ms; that tail is noise from a laptop running Docker, not the application, so
  the median is the number to trust here. The seat count came out at exactly 40 every single time,
  which is the part that actually matters.
- **Runtime choice.** The same script against Sail's stock PHP development server manages
  119 req/s at a p95 of 1.74 s. Octane/Swoole is roughly **14× the throughput at ~1/13th the
  latency**, which is why the Compose file overrides the serve command.

An earlier run with the rate limit deliberately lowered is worth reporting because it shows the
throttle working: throughput flat-lined at the cap (6,000/min × 2 windows) with the rest returning
`429`. The limit is a safety valve, not a bottleneck — see the tradeoff in
[Assumptions or tradeoffs](#assumptions-or-tradeoffs).

---

## Assumptions or tradeoffs

### Domain simplifications

- **One restaurant, one dining room.** Capacity is *seats per start time*, not individual tables,
  so there is no table assignment or table-joining logic.
- **A 90-minute turn is recorded but not deducted from later slots.** `ends_at` is stored on the
  reservation, but a 19:00 booking does not consume 19:30 capacity. Modelling overlap properly
  means claiming every slot a turn spans — a straightforward extension of the same Lua script, but
  it adds multi-key atomicity concerns that felt out of scope here.
- **Service hours are a single window per day** (17:00–22:00 by default) with no per-weekday rules,
  holidays or blackout dates.
- **Parties above 12 are rejected** and pointed at an events team — a stand-in for the external
  system such a booking would really go to.
- **Times are restaurant-local wall clock** in `RESERVATIONS_TIMEZONE` (UTC by default) and stored
  in UTC. There is no per-customer timezone handling.

### Engineering tradeoffs

- **Redis is the gate, MySQL is the record.** This buys an O(1) capacity check with no hot-row
  database lock, at the cost of two systems that must agree. The failure paths are handled
  (compensating release, cold-counter rebuild, `reservations:verify` to audit), but a crash in the
  microseconds between claiming a seat and inserting the row would leak seats until that counter is
  rebuilt.
- **The rate limit default is deliberately high** (`300000`/min) so that load testing measures the
  application rather than the throttle. A real deployment would set it far lower and key it per
  authenticated client rather than per IP.
- **No authentication on the MCP endpoint.** It is open, which is right for an exercise and wrong
  for production; `laravel/mcp` supports Sanctum and OAuth 2.1 middleware on the same route. The
  voice webhook *is* authenticated, because an open booking webhook on a public URL is a different
  kind of mistake.
- **The AI phrasing layer is deliberately cosmetic and off by default.** It cannot choose times or
  book anything, it is cached, and it degrades to the written message — a model is not something I
  would put in the decision path of a booking.
- **No queue.** Booking is fully synchronous so the agent gets a real confirmation rather than a
  promise. Anything slow that a real system would do here (confirmation email, POS sync) belongs on
  a queue; nothing like that is mocked in.
- **Octane means state lives between requests.** Services are container-*bound* rather than
  singletons so each resolution re-reads config, and the code holds no static mutable state.
- **Node/Vite are untouched** — there is no UI, as the exercise does not require one.

---

## Constraints

How this submission respects each constraint:

- **Must be a Laravel project, reproducible with Laravel Sail.** It is a standard Laravel 13
  application (PHP 8.3+, running on the PHP 8.5 Sail runtime); `sail up` builds and runs the whole
  stack (app + MySQL + Redis) with no local toolchain.
- **The core deliverable is the MCP tool — keep everything else as small as possible.** The
  reservation domain is a handful of small, single-purpose classes; there is no UI, no auth
  scaffolding, and the default Laravel example tests and the `inspire` console stub have been
  removed. Everything beyond the tool (voice webhook, AI phrasing) is optional and isolated.
- **External services can be mocked.** The `laravel/ai` phrasing layer is off by default and falls
  back to a written message, and the voice webhook can be exercised locally with `curl` without any
  Vapi/Retell account. Redis and MySQL run inside Sail.

---

## Bonus features

Each optional bonus item, and where it lives:

- **`laravel/ai`.** With `RESERVATIONS_AI_PHRASING=true`, the "fully booked" sentence is rewritten
  by a [HostAgent](app/Ai/Agents/HostAgent.php) so a turned-away guest hears something warmer than
  a template. The design point is what the model is *not* allowed to do: which times to offer is
  decided by `SlotCalendar` and `SlotAvailability`, and the model only phrases that decided list,
  so a bad generation is a clumsy sentence rather than a wrong booking. It is cached per slot and
  party size, capped at 40 words, falls back to the written message on any error, and is **off by
  default** (it would otherwise put a network call on the fastest path in the app and make the
  benchmarks untrue). Enabling it needs a provider key such as `ANTHROPIC_API_KEY`.
- **Voice agent integration (Vapi / Retell).** `POST /webhooks/voice/reservation` accepts both
  platforms' function-call payloads against the same reservation service — see
  [From a voice agent](#from-a-voice-agent-vapi--retell). Signatures are verified and fail closed.
- **Caching.** `check-availability` reads through a 5-second Redis cache
  (`RESERVATIONS_AVAILABILITY_CACHE_SECONDS`), so repeated "what's free tonight?" questions cost
  nothing. Bookings deliberately *never* read that cache — they always go through the atomic claim
  — so a stale read can never cause an overbooking.
- **Rate limiting.** The MCP route carries `throttle:mcp`, defined in
  [AppServiceProvider](app/Providers/AppServiceProvider.php) and keyed by IP, with the limit from
  `MCP_RATE_LIMIT_PER_MINUTE`; responses carry `X-RateLimit-Limit` / `X-RateLimit-Remaining`. The
  voice webhook gets its own, far tighter `throttle:voice` — a phone line makes a handful of calls
  a minute, not thousands.
- **How I would scale this.** A dedicated section: [How this would scale](#how-this-would-scale).

---

## How it works

```
MCP client ──POST /mcp/reservations──▶ Octane/Swoole worker (app stays booted in memory)
                                         │
                          throttle:mcp ──┤
                                         ▼
                              CreateReservationTool          validation + MCP schema
                                         ▼
                               ReservationService            the only place bookings happen
                             ┌───────────┴────────────┐
                             ▼                        ▼
                   SlotAvailability (Redis)    Reservation (MySQL)
                   Lua: check + INCRBY         durable record, unique
                   in ONE atomic call          idempotency_key
```

**Why it is safe under concurrency.** Seats sold per slot live in a Redis counter, and the only
thing allowed to move that counter is a Lua script that reads capacity, compares, and increments
inside a single Redis call. Redis executes scripts atomically, so two workers chasing the last two
seats cannot both win — there is no read-then-write window to lose, and no database row lock to
serialise on. The reservation row is written to MySQL afterwards; if that write fails the seats are
released again.

**Why MySQL still matters.** Redis is a fast gate, not the record. If a counter is missing (Redis
restarted, key expired), it is rebuilt from `SUM(party_size)` in MySQL and published with
`SET ... NX`, so the first worker through wins the race and later ones do not clobber claims made in
between. A test covers exactly this (`test_availability_counters_survive_a_cold_cache`).

**Alternatives** are found by asking Redis for the surrounding slots in one `MGET`, then keeping the
closest ones that still fit the party — one round trip, not one per candidate.

**Idempotency.** `idempotency_key` is a unique column. A retrying agent gets the original
reservation back (`already_booked: true`) instead of a second table, and a race on the same key is
caught at the constraint and compensated.

### Files worth looking at

| Path | What it holds |
| --- | --- |
| [app/Mcp/Tools/CreateReservationTool.php](app/Mcp/Tools/CreateReservationTool.php) | The MCP tool: input schema, validation, output schema |
| [app/Mcp/Servers/ReservationServer.php](app/Mcp/Servers/ReservationServer.php) | Server definition and the instructions agents read |
| [app/Services/Reservations/SlotAvailability.php](app/Services/Reservations/SlotAvailability.php) | The Redis Lua capacity gate |
| [app/Services/Reservations/ReservationService.php](app/Services/Reservations/ReservationService.php) | Booking flow, idempotency, compensation |
| [app/Services/Reservations/PendingReservation.php](app/Services/Reservations/PendingReservation.php) | The requested booking, typed; owns the field→column mapping |
| [app/Services/Reservations/SlotCalendar.php](app/Services/Reservations/SlotCalendar.php) | Slot grid, service hours, candidate alternatives |
| [app/Services/Reservations/RestaurantPolicy.php](app/Services/Reservations/RestaurantPolicy.php) | The single source of truth: every setting and every input rule |
| [config/reservations.php](config/reservations.php) | Every restaurant rule, all env-backed |
| [docker/start-app.sh](docker/start-app.sh) | Boot: key, migrate, then Octane |
| [app/Http/Controllers/VoiceReservationController.php](app/Http/Controllers/VoiceReservationController.php) | Voice platform payload → the same service |
| [app/Http/Middleware/VerifyVoiceSignature.php](app/Http/Middleware/VerifyVoiceSignature.php) | Webhook authentication, fails closed |
| [app/Services/Reservations/AlternativePhrasing.php](app/Services/Reservations/AlternativePhrasing.php) | Optional `laravel/ai` wording layer |

---

## Tests

```bash
./vendor/bin/sail artisan test
```

21 feature tests, driving the tools through the MCP layer itself and the webhook over HTTP:

- **Booking** — a slot that fills exactly to capacity and not one seat further, alternatives when
  full, idempotent retries, rejected times (including past dates), input validation, counter
  rebuilding after Redis is flushed.
- **Voice** — both platform payload shapes, signature verification (unsigned, mis-signed and
  unconfigured all refused), contact-detail rules, spoken validation errors, replayed turns.
- **AI phrasing** — that an enabled model rewrites only the sentence and never the offered times,
  and that a failing or rambling provider falls back to the written message.

Static analysis and formatting are also wired up: `composer analyse` (PHPStan/Larastan level 6) and
`composer test:lint` (Pint), and both run in CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)).

---

## How this would scale

The current shape already removes the usual bottleneck: capacity is decided by one atomic Redis
call, not by a database row lock, so concurrent bookings for the same slot do not queue behind each
other in MySQL. Scaling from there is mostly horizontal.

**Next 10×.** Run several Octane containers behind a load balancer. They are stateless — all shared
state is in Redis and MySQL — so this is a replica count, nothing more. MySQL sees one small INSERT
per confirmed booking and nothing at all for a rejection, and availability reads are absorbed by
the cache, so a single primary carries this comfortably.

**Next 100×.** Two changes. First, move the durable write off the request path: the Redis claim is
what actually reserves the seat, so the row can be written by a queued job and the guest still gets
an immediate, truthful confirmation. That converts bursts into queue depth instead of database
pressure, at the cost of a short window where a reservation exists in Redis but not yet in MySQL —
acceptable for a booking, not for a payment. Second, split reads from writes with a replica serving
the counter-rebuild query and any reporting.

**Beyond that,** slot counters shard naturally: the key is `slot:<timestamp>`, so a Redis Cluster
spreads them across nodes with no coordination between slots, and a multi-venue tenant would prefix
by restaurant. The per-key ceiling is Redis's single-threaded throughput — far past what any one
dining room can physically seat.

**What I would add before any of that:** authentication on the MCP endpoint (Sanctum or OAuth 2.1,
both supported by `laravel/mcp`), per-client rather than per-IP rate limits, and metrics on claim
latency and rejection rate so saturation shows up before guests feel it.

---

## Configuration

Everything is env-backed; see [.env.example](.env.example) and
[config/reservations.php](config/reservations.php).

| Variable | Default | Meaning |
| --- | --- | --- |
| `RESERVATIONS_TIMEZONE` | `UTC` | Timezone all dates/times are interpreted in |
| `RESERVATIONS_OPENING_TIME` / `CLOSING_TIME` | `17:00` / `22:00` | First and last bookable seating |
| `RESERVATIONS_SLOT_MINUTES` | `30` | Spacing of the slot grid |
| `RESERVATIONS_TURN_MINUTES` | `90` | How long a table is held (informational) |
| `RESERVATIONS_SEATS_PER_SLOT` | `40` | Capacity per start time |
| `RESERVATIONS_MAX_PARTY_SIZE` | `12` | Largest party bookable online |
| `RESERVATIONS_ALTERNATIVE_WINDOW_SLOTS` | `4` | How far either side to search (4 × 30 min = ±2 h) |
| `RESERVATIONS_AVAILABILITY_CACHE_SECONDS` | `5` | TTL of the availability read cache |
| `MCP_RATE_LIMIT_PER_MINUTE` | `300000` | Per-IP cap on the MCP endpoint |
| `VOICE_WEBHOOK_SECRET` | *(empty)* | Shared secret for the voice webhook; empty disables it |
| `VOICE_RATE_LIMIT_PER_MINUTE` | `120` | Per-IP cap on the voice webhook |
| `RESERVATIONS_AI_PHRASING` | `false` | Let `laravel/ai` phrase the "fully booked" line |
| `RESERVATIONS_AI_CACHE_SECONDS` | `300` | How long a generated sentence is reused |
| `OCTANE_WORKERS` | `auto` | Swoole workers (`auto` = one per CPU) |

Maintenance

ActivityMaintained
ResponsivenessNo issues