A production-ready REST API for managing flights with nested legs and segments.
Built with Laravel 12, Horizon, Redis, and MySQL.
Quickstart · API · Admin · Testing · Performance · Architecture
| 3 API endpoints | Create, Update (async), and Get flights with nested legs & segments |
| Idempotent updates | Idempotency-Key header guarantees exactly-once processing |
| Async queue | Updates dispatched to Redis, processed by Horizon workers |
| API key auth | All endpoints protected via Api-Key header |
| Rate limiting | Configurable per-key throttle (default 200 req/min) |
| Admin dashboard | Stats, flights, env & system info at /admin |
| OpenAPI 3.0 | Interactive Swagger UI at /docs |
| 57 Pest tests | Unit, feature, architecture, performance (100% coverage) |
| k6 load tests | Smoke, load, and spike scenarios |
Prerequisites: Docker Desktop and Composer
git clone https://github.com/khaledalam/flight-signals.git && cd flight-signals
composer install
cp .env.example .env
php artisan key:generate
# With Make
make up # Start app + MySQL + Redis + Horizon
make migrate # Run migrations
# Without Make
./vendor/bin/sail up -d
./vendor/bin/sail artisan migrateThe API is now live at http://localhost:8080.
The port is controlled by
APP_PORTin.env(default:8080).
| Service | URL | Auth |
|---|---|---|
| Homepage | http://localhost:8080 | -- |
| API | http://localhost:8080/api/flights | Api-Key header |
| Swagger UI | http://localhost:8080/docs | -- |
| Admin Dashboard | http://localhost:8080/admin | ADMIN_USERNAME / ADMIN_PASSWORD (basic auth) |
| Horizon | http://localhost:8080/horizon | ADMIN_USERNAME / ADMIN_PASSWORD (basic auth) |
| Health check | http://localhost:8080/up | -- |
Shut down
make down
# or
./vendor/bin/sail down| Method | Path | Description | Auth | Status |
|---|---|---|---|---|
POST |
/api/flights |
Create a flight | Api-Key |
201 |
PUT |
/api/flights/{flightId} |
Update a flight (async) | Api-Key + Idempotency-Key |
204 |
GET |
/api/flights/{flightId} |
Get a flight | Api-Key |
200 |
Interactive Swagger UI at http://localhost:8080/docs · OpenAPI spec at openapi/openapi.json
Postman: Import openapi/openapi.json via File → Import to get the full collection.
Postman screenshots
Create Flight (201) |
Update Flight (204) |
|---|---|
![]() |
![]() |
Flight Not Found (404) |
Missing Api-Key (401) |
|---|---|
![]() |
![]() |
POST /api/flights -- Create a flight (full example with 2 legs)
curl -s -X POST http://localhost:8080/api/flights \
-H "Content-Type: application/json" \
-H "Api-Key: your-secret-api-key-here" \
-d '{
"legs": [{
"segments": [{
"origin": "BCN", "destination": "LON",
"departure": "2026-06-09T06:45:00", "arrival": "2026-06-09T10:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "101"
}, {
"origin": "LON", "destination": "JFK",
"departure": "2026-06-09T11:55:00", "arrival": "2026-06-09T14:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "102"
}]
}, {
"segments": [{
"origin": "JFK", "destination": "LON",
"departure": "2026-06-25T06:45:00", "arrival": "2026-06-25T10:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "101"
}, {
"origin": "LON", "destination": "BCN",
"departure": "2026-06-25T11:55:00", "arrival": "2026-06-25T13:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "102"
}]
}]
}' | jq .Response 201:
{ "flightId": "019cb527-4564-73da-b8c2-65b369738eda" }PUT /api/flights/{flightId} -- Update a flight (partial -- first leg only)
curl -s -X PUT http://localhost:8080/api/flights/019cb527-4564-73da-b8c2-65b369738eda \
-H "Content-Type: application/json" \
-H "Api-Key: your-secret-api-key-here" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"legs": [{
"segments": [{
"origin": "BCN", "destination": "LON",
"departure": "2026-06-09T06:40:00", "arrival": "2026-06-09T10:50:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "101"
}, {
"origin": "LON", "destination": "JFK",
"departure": "2026-06-09T11:55:00", "arrival": "2026-06-09T14:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "102"
}]
}]
}'Response: 204 No Content
GET /api/flights/{flightId} -- Get a flight
curl -s http://localhost:8080/api/flights/019cb527-4564-73da-b8c2-65b369738eda \
-H "Api-Key: your-secret-api-key-here" | jq .Response 200:
{
"legs": [{
"segments": [{
"origin": "BCN", "destination": "LON",
"departure": "2026-06-09T06:40:00", "arrival": "2026-06-09T10:50:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "101"
}, {
"origin": "LON", "destination": "JFK",
"departure": "2026-06-09T11:55:00", "arrival": "2026-06-09T14:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "102"
}]
}, {
"segments": [{
"origin": "JFK", "destination": "LON",
"departure": "2026-06-25T06:45:00", "arrival": "2026-06-25T10:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "101"
}, {
"origin": "LON", "destination": "BCN",
"departure": "2026-06-25T11:55:00", "arrival": "2026-06-25T13:55:00",
"cabinClass": "Y", "airline": "UA", "flightNumber": "102"
}]
}]
}Idempotency -- The PUT /api/flights/{flightId} endpoint requires an Idempotency-Key header. The system guarantees exactly-once processing:
- The request takes an atomic Redis lock (
Cache::lock()) on the key, scoped to the flight. - First request -- update job dispatched,
204response stored in Redis with a TTL (IDEMPOTENCY_TTL, default 24h). No database table, so nothing to purge. - Replay with same key -- stored
204returned immediately, no duplicate job. - Same key while the first request still holds the lock --
409 Conflict. - Same key with a different payload --
422. - If the job permanently fails, the key is released so the client can retry with it.
Segment times are local to each airport (YYYY-MM-DDTHH:MM:SS, no offset). Origin/destination must be a known IATA airport or metro code (resources/data/airport-timezones.php), which is used to convert both times to UTC before comparing:
- a segment must arrive after it departs (e.g.
SYD 10:00 -> LAX 06:00same date is valid;JFK 06:45 -> LHR 10:55is not), - each segment must depart after the previous segment of its leg arrives,
- each leg must depart after the previous leg arrives,
- origin and destination must differ,
- at most 10 legs per flight and 8 segments per leg. Oversized payloads are rejected before per-segment validation runs.
On update, every leg in the payload must match an existing leg by route (otherwise 422), and the merged itinerary must still be in order.
Rate Limiting -- All API endpoints are throttled to 200 req/min per Api-Key (configurable via API_RATE_LIMIT). Exceeding the limit returns 429 Too Many Requests.
| URL | /admin |
| Username | ADMIN_USERNAME env var |
| Password | ADMIN_PASSWORD env var |
Both /admin and /horizon are locked until these are set.
The dashboard displays:
- Stats -- flights, legs, segments, pending/failed jobs
- Recent flights -- last 10 with route summary and timestamps
- Environment -- app config, database, queue, cache, rate limit
- System info -- PHP/Laravel version, OS, memory limits, timezone, Horizon prefix
88 Pest tests with 100% code coverage, stable under --parallel. Tests use SQLite in-memory -- no Docker needed.
composer test # Run all tests
composer test:coverage # With coverage report
composer test:perf # Performance tests only
make test # Via Sail
make cover # Coverage via SailTest suites breakdown
| Suite | Tests | Covers |
|---|---|---|
RouteSignatureTest |
5 | Route signature building, ordering, edge cases |
FlightServiceTest |
7 | Create, positions, camelCase mapping, partial update, one-to-one leg matching |
UpdateFlightJobTest |
3 | Flight-not-found early return, processing, key released on failure |
AdminServiceTest |
4 | Dashboard stats, recent flights, job counts, env |
HorizonGateTest |
4 | Basic auth required, wrong credentials, gate backstop |
AdminDashboardTest |
6 | Env-based credentials, no admin:admin, locked when unconfigured |
AuthenticationTest |
4 | Missing/invalid Api-Key, unconfigured key fails closed |
CreateFlightTest |
6 | Happy path, validation errors, data persistence |
GetFlightTest |
3 | Retrieval, 404 for unknown and malformed ids |
UpdateFlightTest |
5 | Job dispatch, 204 response, actual data update, validation |
ItineraryValidationTest |
19 | Segment/leg sequence, timezone-aware times, airport codes, size caps, update matching |
IdempotencyTest |
7 | Replay, lock conflict (409), payload mismatch, per-flight scope, retry after failure |
CommandsTest |
4 | flights:stats, flights:inspect |
RateLimitingTest |
1 | 429 after exceeding threshold |
PerformanceTest |
6 | Endpoint latency budgets, P95 regression, large payloads, write-free replays |
ArchitectureTest |
4 | Layer boundaries (controllers, models, jobs, services) |
| Endpoint | Budget | Checks |
|---|---|---|
POST /api/flights |
< 200ms | Single create + 50-request sustained throughput (avg + P95) |
GET /api/flights/{id} |
< 100ms | Normal + 10-leg/30-segment large payload |
PUT /api/flights/{id} |
< 200ms | Dispatch latency; replays do no DB writes and dispatch no job |
Three scenarios in tests/Load/k6-flights.js:
| Scenario | VUs | Duration | Purpose |
|---|---|---|---|
| Smoke | 1 | 10s | Sanity check, baseline latency |
| Load | 0 → 10 → 0 | 50s | Moderate sustained concurrency |
| Spike | 0 → 30 → 0 | 20s | Sudden burst handling |
brew install k6 # Install k6
make load # All scenarios (auto-adjusts rate limit)
make load-smoke # Quick smoke test (1 VU, 10s)
make loadsetsAPI_RATE_LIMIT=10000before running and restores200when done.
Sample k6 output
╔══════════════════════════════════════════════╗
║ Flight Signals -- Load Test Report ║
╚══════════════════════════════════════════════╝
Create P95 142.3ms
Create P99 287.1ms
Get P95 28.4ms
Get P99 61.2ms
Update P95 95.7ms
Update P99 183.4ms
Error Rate 0.00%
Flights Created 847
┌─────────┐ ┌─────────────┐ ┌──────────────┐ ┌───────┐
│ Client │────▶│ Middleware │────▶│ Controller │────▶│ MySQL │
│ │ │ (Api-Key) │ │ │ └───────┘
└─────────┘ │ (Throttle) │ │ ┌────────┐ │
└─────────────┘ │ │Service │ │ ┌───────┐
│ └────┬───┘ │────▶│ Redis │
│ │ │ └───┬───┘
└───────┼──────┘ │
│ ┌────▼────┐
│ │ Horizon │
│ │ Worker │
│ └────┬────┘
└────────────────┘
(UpdateFlightJob)
Key design decisions
Data model: Flight → Legs → Segments with positional ordering. Flights use UUIDs.
Leg matching on update: Legs are matched by route signature -- the ordered origin→destination chain of segments (e.g., BCN>LON|LON>JFK). This allows partial updates while correctly identifying which leg to modify.
Async updates: The update endpoint validates input synchronously, takes a Redis idempotency lock, then dispatches an UpdateFlightJob to Redis. The job runs in Horizon with 3 retries and exponential backoff (5s, 30s, 60s). The flight row is locked (SELECT ... FOR UPDATE) while a job applies its changes, so concurrent updates are serialised.
Thin controllers: Controllers handle HTTP concerns only. Business logic lives in FlightService.
make shell # Shell into the container
# or: ./vendor/bin/sail shellflights:stats -- Database overview
$ sail artisan flights:stats
INFO Flight Signals -- Database Stats.
+---------------------+----------------+
| Metric | Value |
+---------------------+----------------+
| Flights | 1 |
| Legs | 2 |
| Segments | 4 |
| Avg legs/flight | 2 |
| Avg segments/leg | 2 |
| Last created | 27 minutes ago |
+---------------------+----------------+flights:inspect {id} -- Display a flight
$ sail artisan flights:inspect 019cb527-4564-73da-b8c2-65b369738eda
INFO Flight 019cb527-4564-73da-b8c2-65b369738eda.
Created: 2026-03-03 19:22:55
Updated: 2026-03-03 19:22:55
Leg 1 ............................................ 2 segment(s)
+------+-----+------------------+------------------+--------+-------+
| From | To | Departure | Arrival | Flight | Cabin |
+------+-----+------------------+------------------+--------+-------+
| BCN | LON | 2026-06-09 06:40 | 2026-06-09 10:50 | UA 101 | Y |
| LON | JFK | 2026-06-09 11:55 | 2026-06-09 14:55 | UA 102 | Y |
+------+-----+------------------+------------------+--------+-------+
Leg 2 ............................................ 2 segment(s)
+------+-----+------------------+------------------+--------+-------+
| From | To | Departure | Arrival | Flight | Cabin |
+------+-----+------------------+------------------+--------+-------+
| JFK | LON | 2026-06-25 06:45 | 2026-06-25 10:55 | UA 101 | Y |
| LON | BCN | 2026-06-25 11:55 | 2026-06-25 13:55 | UA 102 | Y |
+------+-----+------------------+------------------+--------+-------+Other useful commands
sail artisan migrate # Run migrations
sail artisan migrate:fresh # Reset database
sail artisan horizon # Start Horizon worker
sail artisan route:list # Show all routes
sail artisan queue:failed # List failed jobs
sail artisan queue:retry all # Retry all failed jobs| Variable | Description | Default |
|---|---|---|
APP_PORT |
Host port | 8080 |
API_KEY |
Secret for Api-Key header |
your-secret-api-key-here |
DB_CONNECTION |
Database driver | mysql |
DB_HOST |
Database host | mysql |
DB_DATABASE |
Database name | laravel |
DB_USERNAME |
Database user | sail |
DB_PASSWORD |
Database password | password |
QUEUE_CONNECTION |
Queue driver | redis |
REDIS_HOST |
Redis host | redis |
API_RATE_LIMIT |
Max requests/min per key | 200 |
All variables are in .env.example. Never commit .env.
Tooling
| Tool | Purpose | Command |
|---|---|---|
| Pint | Code style | composer fix / composer lint |
| Pest | Testing + perf | composer test / composer test:perf |
| k6 | Load testing | make load / make load-smoke |
| Horizon | Queue dashboard | http://localhost:8080/horizon |
| Swagger UI | API docs | http://localhost:8080/docs |
| Admin Dashboard | Stats, env, flights | http://localhost:8080/admin |
| Pre-commit hook | Auto-lint on commit | bash scripts/install-hooks.sh |
Makefile targets
make help # Show all targets
make up # Start services
make down # Stop services
make fresh # Rebuild + migrate
make test # Run Pest via Sail
make cover # Tests + coverage
make perf # Performance tests + profiling
make profile # All tests with slowest highlighted
make load # k6 load test (all scenarios)
make load-smoke # k6 smoke test (1 VU, 10s)
make lint # Check code style
make fix # Auto-fix code style
make shell # Shell into container
make logs # Tail app logsapp/
├── Console/Commands/
│ ├── FlightInspect.php
│ └── FlightsStats.php
├── Http/
│ ├── Controllers/{FlightController,AdminController}.php
│ ├── Middleware/{AuthenticateApiKey,BasicAuthAdmin}.php
│ └── Requests/{FlightPayload,CreateFlight,UpdateFlight}Request.php
├── Jobs/UpdateFlightJob.php
├── Models/{Flight,Leg,Segment}.php
├── Providers/{App,Horizon}ServiceProvider.php
├── Services/{Flight,Idempotency,Admin}Service.php
└── Support/{AirportTimezones,ItineraryValidator}.php
database/migrations/
resources/data/airport-timezones.php # IATA code -> IANA timezone (airports from mwgg/Airports, MIT)
docs/media/ # Screenshots (homepage.png, admin.png)
openapi/openapi.json
tests/
├── Unit/{RouteSignature,FlightService,UpdateFlightJob,AdminService,HorizonGate}Test.php
├── Feature/{Authentication,CreateFlight,GetFlight,UpdateFlight,ItineraryValidation,Idempotency,RateLimiting}Test.php
├── Feature/{Performance,Architecture}Test.php
└── Load/k6-flights.js
Khaled Alam






