AdventureList is a social travel tracking app that helps you explore your local community: save cool places you want to visit, log the places you've been, organize your favorite places into lists, rate and review places as you visit them, share your travels with others, and follow your friends to see where they've been recently. Like Letterboxd for all kinds of places: restaurants, national parks, neighborhoods, coffee shops, bars, museums, and more.
This repo is the full-stack web app. There's also a React Native mobile app (incomplete) that talks to the same API.
Note
This is a side project I created for my own personal use, it's mostly feature complete at this point and not under active development. It's live in production at https://adventurelist.app/
- Planning — save places you want to visit and browse them on a map, filter by what's near you
- Visits — log the places you've visited, with ratings and reviews and photos
- Lists — organize places into custom lists (favorite coffee shops, date night spots, national parks bucket list, etc.)
- Social — follow friends to see where they've been and what they're planning
- Smart place entry — add a place by searching, or just paste a Google Maps link and the details fill in automatically
- Maps everywhere — every list, plan, and visit can be viewed as pins on an interactive map, not just a list of names
- List bookmarking — bookmark lists made by other people for quick reference later
- Logbook — look back on your adventures using a reverse-chronological timeline view
- Travel stats — see all the countries you've visited, photos you've taken, and places you've visited on your profile
- City exploration — curate your planning list, easily find places to visit when you're out and about
- Trip planning — create a list for an upcoming trip, link visits and photos in there to create a vacation travel journal
- Social discovery — follow people you trust and bookmark their lists to find new places instead of relying on generic recommendations
- Backend: Laravel 12 (PHP 8.4), PostgreSQL with PostGIS, Sanctum for API auth
- Frontend: React 19 + TypeScript, server-routed through Inertia.js (no separate SPA build/API contract), Chakra UI, Tailwind CSS
- Maps & location: Mapbox for rendering, LocationIQ for search/geocoding
- Testing: Pest (feature/unit), Playwright (e2e)
- Infra: Hosted on DigitalOcean App Platform, images via Cloudinary, analytics via PostHog, errors via Sentry
When a place is saved, it'd be nice to automatically tag it as food / drink / coffee / activity rather than asking the user to pick every time.
app/Services/PlaceClassifier.php sends the place's name and metadata to an LLM (Perplexity Sonar via OpenRouter) with a prompt built from an explicit rule set: hard exclusions (a city, state, or neighborhood should never be classified), per-category inclusion/exclusion examples, and examples for places that span multiple categories (a bookstore with a coffee shop inside is both activity and coffee; a bar with a kitchen is drink and food).
The model is asked to return strict JSON with categories, a confidence score, and its reasoning. All the LLM evaluations are stored in the places table to make debugging issues easier.
I also built a simple LLM eval harness inside tests/Eval/. Prompt-engineering a classifier requires preventing and fixing regressions while the prompt gets tightened, so there's a hand-curated golden set (fixtures/classifier_golden_set.json) of real, sometimes-ambiguous places, and a test runner that hits the real model for each one and renders a readable pass/fail report — model used, confidence, expected vs. actual categories, and the LLM's own reasoning for the call. It's deliberately excluded from the normal test run (php artisan test tests/Eval) since it costs money and talks to a live API, but it makes iterating on the prompt a lot easier and exposes evaluation regressions immediately.
Status: the backend classifier and eval suite v1 are done and the rules have been tuned through several iterations; the frontend only surfaces category filtering on one tab (planning/index.tsx) and isn't wired up everywhere yet. Including it here because the classification logic itself was fun to build and it was my first time designing and implementing an LLM-based system.
I noticed that I would frequently look a place up in Google Maps and then want to add it to my AdventureList. To make this as easy as possible I implemented a system to parse Google Maps share links.
app/Services/GoogleMapsParser.php follows redirects on shortened URLS (like maps.app.goo.gl, guarded by a domain allowlist) to get the canonical link, then tries different parsing strategies in order: @lat,lng segments, !3d!4d coordinate pairs, and /place/Name,Address/ segments — since Google's share URL format varies depending on the platform and how the link was shared.
This turned out to be pretty reliable at getting the place name, address, and coordinates when saving Google Maps links from my phone and is much easier than manually copying the name and address over. A future iteration could include extracting place information from shared Instagram and TikTok posts, since that's where a lot of place recommendations come from.
The free-text search box on the "save a place" page needs a provider to turn what you type into real places with coordinates, and I wasn't sure up front which one would give the best results. I tried a few (LocationIQ, Mapbox's Search Box API, plain OSM/Nominatim lookups) and wanted to A/B them without rewriting the search page every time.
The fix was App\Data\PlaceResponseDTO — every provider's response gets normalized into the same DTO (name, address, lat/lng, bbox, plus provider-specific ids like locationiq_id/osm_id/mapbox_id for later lookups) before it reaches SearchController or the frontend. Swapping providers was then just a matter of pointing the controller at a different service — the search page, the autocomplete dropdown, and the place-save flow never had to know which API actually answered the query.
I ended up landing on LocationIQ for search/autocomplete and Mapbox for map rendering, but the DTO boundary is still there, so revisiting that decision later wouldn't mean tearing up the frontend.
I tested the Google Maps Places API early on, and the place data quality is the best of any provider I tried. I didn't go with it for a few reasons:
- Cost — Google's pricing is steep at any real scale, especially compared to LocationIQ's free tier and Mapbox's generous one.
- Map lock-in — Google's terms require any place data from their API to be displayed on a Google Maps front end, which would have ruled out Mapbox GL entirely — including the globe view I wanted across the app.
- Storage restrictions — Google's terms also prohibit persisting most place data beyond a short cache window, which is a dealbreaker for an app whose whole point is to store places long-term. Every place page would have had to re-fetch from Google to stay compliant, coupling the entire architecture to their API and availability.
LocationIQ + Mapbox isn't as good at resolving ambiguous queries and has a less complete world dataset, but it's cheap/free, lets the data actually live in adventuredb, and keeps the different layers swappable. I chose simplicity and data independence over a perfect autocomplete experience.
Zooming out on a list with hundreds of places shouldn't turn into a wall of overlapping pins, so the map source has Mapbox's built-in clustering turned on (cluster: true), with a separate layer that draws the count and a click handler that flies into a cluster's expansion zoom rather than just zooming in blindly. For the "places near me" view, a circle polygon is computed on the fly from a center point and a radius in miles (createCircleFeature, converting miles to a 64-point polygon in degrees) and redrawn as a GeoJSON layer every time the radius or location changes, so the search area is always visible rather than just implied by which pins happen to be showing.
Places store their coordinates as a real PostGIS Point geometry with a GiST index, not just lat/lng floats. app/Traits/BelongsToPlace.php exposes scopeWithinRadius() and scopeOrderByDistance(), which compile down to ST_DWithin/ST_Distance geography queries — shared across Visit and PlanningEntry so "places near me" works the same way whether you're looking at things you've already been or things on your list.
Visit and PlanningEntry are independent tables keyed on (user_id, place_id) with no constraint between them, which means a place can legitimately be both "on the list" and "already been there" at the same time — useful for places you'd happily visit again. This intentionally replaced an earlier model (TravelEntry, still present) that used a single boolean visited flag and couldn't represent that case.
composer install
npm install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
composer dev # runs the PHP server, queue worker, log tailing, and Vite togetherRequires a local PostgreSQL database with the PostGIS extension enabled.
You'll also need API keys for the external services in .env:
| Variable | Used for |
|---|---|
MAPBOX_API_KEY |
Map rendering/geocoding (Mapbox) |
LOCATIONIQ_API_KEY |
Place search/autocomplete (LocationIQ) |
OPENROUTER_API_KEY |
LLM place classification (OpenRouter) |
CLOUDINARY_URL |
Image uploads/CDN (Cloudinary) |
All four have free tiers sufficient for local development.
php artisan test --compact # Pest feature/unit tests
npm run test:e2e # Playwright e2e testsMIT