A premium travel company came to us with a clear brief: build a platform that connects affluent travelers with verified local experts across the globe. Not a template. Not a booking widget bolted onto WordPress. A custom-engineered marketplace with AI-powered trip planning, multi-party payments, and three distinct user portals.
The result is Tailored by Locals. Roughly 35 Eloquent models, 95 database migrations, 5 user roles, 3 portals (traveler-facing site, agency dashboard, admin panel), a RAG-powered AI chatbot, and Stripe Connect for marketplace payments. Built with Laravel 12, Vue 3, and Inertia.js v2 with server-side rendering.
This article walks through the engineering decisions behind the platform. Not a tutorial. Not a theoretical architecture guide. A real-world Laravel application, from the first migration to production deployment, with the reasoning behind every significant choice.
Why a Laravel Monolith
The first architectural decision was also the most consequential: build a monolith, not microservices.
In 2025 and 2026, the industry has been quietly walking back the microservices-by-default mindset. Teams at Shopify and GitLab have documented the benefits of modular monoliths. The emerging consensus is that teams under 10 developers perform better with monolithic architecture, where Docker and service orchestration add complexity without clear benefits at that scale.
For Tailored by Locals, the monolith was the obvious choice. Across 200+ projects over 15 years, we’ve seen the monolith-vs-microservices decision play out many times. The answer almost always comes down to team size and data coupling. One development team. Shared data models across all three portals. No need for independent scaling of isolated services. Rapid iteration was more important than distributed deployment flexibility. The traveler portal, agency dashboard, and admin panel all operate on the same data, so splitting them into separate services would have created an integration tax on every feature.
The organizational principle we settled on: Actions. Not fat controllers, not service classes with 15 methods each. Single-purpose Action classes that encapsulate one piece of business logic. AssignAgencyToTrip, ProcessBookingPayment, GenerateDestinationEmbedding. Each one does exactly what its name says. When you have 35 models, this discipline is what keeps the codebase navigable six months after the initial build.
This doesn’t mean monolith is always the right call. If the platform needed to scale individual components independently (say, the AI chatbot had wildly different resource requirements from the booking engine), or if multiple teams were developing in parallel, microservices or at least a modular monolith with explicit module boundaries would have made more sense. For this project and this team size, a well-organized Laravel monolith was the right tool.
The Stack: Laravel 12 + Vue 3 + Inertia.js v2
The backend runs on Laravel 12 with PHP 8.4. The frontend is Vue 3 with the Composition API. The bridge between them is Inertia.js v2 with server-side rendering enabled.
Why Inertia Over a Separate API
The alternative was building a traditional SPA with Vue Router and a separate REST or GraphQL API. We’ve built both on previous projects, and the choice comes down to whether the API needs to serve multiple consumers. If you’re building a mobile app alongside the web app, a standalone API makes sense. For a web-only platform, Inertia was the better fit.
The biggest win: it eliminates the API layer entirely. No need to build, version, and maintain a set of API endpoints that the frontend consumes. Controllers pass data directly to Vue components through Inertia responses. Validation, authorization, and routing all live in Laravel, using the same patterns any Laravel developer already knows.
Then there’s authentication. With a separate API, you’re managing tokens, CORS, refresh logic, and session state across two systems. With Inertia, authentication is standard Laravel session-based auth. The traveler portal, agency dashboard, and admin panel all share the same auth middleware stack.
And SSR for SEO. A travel platform needs its destination pages, experience listings, and blog content to be crawlable by search engines. Inertia v2’s server-side rendering handles this natively. The SSR server runs alongside the Laravel application on the same Forge-managed deployment, and it renders the initial page load as full HTML before Vue hydrates on the client.
Three Portals, One Codebase
The traveler-facing site, agency dashboard, and admin panel are all part of the same Laravel application. Route groups with middleware handle access control. Each portal has its own layout component in Vue, its own navigation, its own visual identity. But they share the same Eloquent models, the same Action classes, and the same authorization policies.
The routing structure looks roughly like this: public routes serve the traveler site, /agency routes serve the agency dashboard behind agency authentication middleware, and /admin routes serve the admin panel behind admin middleware. Inertia’s persistent layouts mean each portal maintains its own navigation state without interfering with the others.
Vue 3’s Composition API was essential here. Complex components like the multi-step booking flow, the trip builder interface, and the agency availability calendar all benefit from composable logic extraction. Shared composables handle things like date formatting, currency conversion, and form validation across all three portals.
Data Architecture: 35 Models, 95 Migrations
The domain model spans users, agencies, destinations, experiences, trips, bookings, payments, reviews, messages, AI chat sessions, and several supporting entities. 35 Eloquent models at launch. That number will grow.
User Roles and Relationships
Five distinct user roles interact with the platform: Traveler, Agency Owner, Agency Member, Destination Expert, and Admin. Rather than building separate user tables, all roles share a single users table with role-based access control through Laravel’s authorization system. Gates and policies enforce what each role can do, and middleware groups restrict route access by role.
Agencies are their own entity with a one-to-many relationship to agency members. An agency owner can invite team members, manage listings, and handle bookings. Destination experts curate content about specific locations, which feeds into both the traveler experience and the AI chatbot’s knowledge base.
Polymorphic Relationships
Several entities use polymorphic relationships. Reviews can belong to destinations, experiences, or agencies. Media attachments (photos, documents) are polymorphic across multiple parent types. This avoids the common antipattern of creating separate review tables or media tables for each entity type.
Migration Strategy
Why 95 migrations instead of fewer consolidated ones? Because we didn’t know the final schema when we started. Nobody does on a project this size. Iterative development. Each migration represents a discrete change that can be rolled back independently. When you’re building a complex domain model over months, the ability to undo the last schema change without affecting everything else is worth the extra files. It also makes the git history readable. You can trace exactly when a column was added and why.
Key Eloquent patterns used throughout: query scopes for filtering (active destinations, approved agencies, published experiences), accessors and mutators for computed properties, model events for side effects (like triggering embedding generation when destination content changes), and custom casts for value objects like money and coordinates.
The Weighted Agency Assignment Algorithm
When a traveler requests a custom trip, the platform needs to assign the best-matching agency. Not randomly. Not first-come-first-served. Through a weighted scoring algorithm that balances multiple factors.
The Scoring Factors
Each eligible agency receives a composite score based on: destination expertise (how many successful trips they’ve completed in the requested destination), response time history (how quickly they typically respond to inquiries), traveler ratings (aggregate review score), current workload (agencies already handling many active trips score lower), and premium status (verified partner agencies receive a configurable boost).
Why Weighted Random, Not a Simple Sort
The naive approach would be to sort agencies by score and always assign the highest-scoring one. That creates a monopoly problem, and it’s one we’ve seen in other marketplace projects. The top-rated agency gets all the work, newer agencies never get assigned, and the market stagnates.
Instead, the algorithm uses weighted random selection. Higher-scoring agencies are more likely to be selected, but there’s controlled randomness built in. An agency with a score of 85 might be selected 3x more often than one scoring 45, but the lower-scoring agency still gets assignments. This keeps the marketplace healthy and gives newer agencies a path to building their reputation.
The implementation lives in a single Action class: AssignAgencyToTrip. It queries eligible agencies, calculates scores through a configurable set of weight factors, performs the weighted random selection, and returns the assignment. The weight configuration is stored in the database, so the business team can adjust the balance between expertise, speed, ratings, and other factors without a code deployment.
Testing this required a specific approach. Unit tests with known weights and seeded random values verify that the distribution matches expected probabilities over a large number of iterations. This catches regressions that would be invisible in manual testing.
RAG-Powered AI Chatbot
The platform includes an AI trip planning assistant. Travelers can ask questions about destinations, local customs, seasonal considerations, and available experiences. The chatbot responds with contextually relevant information grounded in the platform’s actual data.
Why RAG Over Fine-Tuning
RAG (Retrieval-Augmented Generation) was the clear choice over fine-tuning a model.
The most important reason: data freshness. Destination information, experience availability, and seasonal recommendations change frequently. With RAG, updating the knowledge base is as simple as re-embedding the changed content. Fine-tuning would require retraining the model every time a destination description changes or a new experience is added.
There’s also the accuracy angle. RAG grounds responses in specific, retrievable chunks of verified data. When the chatbot says “the best time to visit Santorini for fewer crowds is late September through October,” that statement traces back to a specific chunk in the vector store that was written and verified by a destination expert. Hallucination risk drops significantly compared to a model generating from its training data.
Cost matters too. Embedding generation runs once per content change and costs fractions of a cent per chunk. The retrieval query itself is fast and cheap. Compare that to the cost and complexity of fine-tuning a model on your specific dataset.
The Architecture
Destination and experience data gets chunked into semantically meaningful segments and embedded using OpenAI’s embedding model. The embeddings are stored in Pinecone, a vector database optimized for similarity search.
When a traveler asks a question, the system embeds the query, retrieves the most relevant chunks from Pinecone, constructs an augmented prompt that includes both the user’s question and the retrieved context, and sends it to OpenAI’s completion API. The response streams back to the Vue component in real time.
On the Laravel side, queue jobs handle embedding generation. When a destination’s content is updated, an UpdateDestinationEmbeddings job dispatches to the queue. This decouples the embedding process from the HTTP request cycle, so content updates don’t block the editorial workflow. Frequent queries are cached in Redis with a TTL, reducing both latency and API costs for common questions.
Rate limiting prevents abuse. Each user gets a configurable number of chatbot interactions per day, enforced through Laravel’s built-in rate limiter.
Headless WordPress for the Blog
The platform needed a blog. We could have built it in Laravel (and we’ve done that before on other projects). But the content team wanted the WordPress editing experience, Yoast for on-page SEO, and the familiar workflow they’d used for years. Building a custom CMS to replicate what WordPress already does well would have been wasted engineering effort.
The Integration
WordPress runs on a subdomain. The Laravel application consumes blog content through the WordPress REST API. On the Laravel side, a service class fetches posts, categories, and tags from the API and renders them within the platform’s design system. From a visitor’s perspective, the blog is seamlessly integrated into the main site. The URL structure, navigation, and visual design are all consistent.
Caching Strategy
Every API response from WordPress gets cached in Redis with tagged cache keys. Tags correspond to content types: blog:posts, blog:categories, blog:tags. When WordPress publishes or updates a post, a webhook fires to the Laravel application, which invalidates the relevant cache tags. This means the blog content is always fresh after an edit, but the vast majority of requests never hit the WordPress API at all.
For teams running headless WordPress alongside Laravel, our WordPress Boost MCP server provides tools for inspecting WordPress internals programmatically, which is useful when debugging API response structures or auditing content.
We’ve also documented our broader approach to WordPress performance optimization, which informed the caching architecture for this integration.
Payments, Caching, and Production Concerns
Stripe Connect for Marketplace Payments
Tailored by Locals is a marketplace, which means payments flow from travelers to the platform and then to agencies. Stripe Connect handles this with connected accounts for each agency. When a traveler books a trip, the payment processes through the platform’s Stripe account, Stripe takes its fee, the platform takes its commission, and the remainder routes to the agency’s connected account.
Webhook handling is where marketplace payments get complicated. Payment events (successful charges, refunds, disputes) arrive asynchronously through Stripe webhooks. A dedicated controller receives these events, verifies the webhook signature, and dispatches them to appropriate Action classes. Failed webhook processing retries automatically through Laravel’s queue system with exponential backoff.
SCA (Strong Customer Authentication) compliance was non-negotiable for European travelers. Stripe’s Payment Intents API handles the 3D Secure flow, and the frontend manages the additional authentication step when the customer’s bank requires it.
Tagged Redis Caching
Beyond the WordPress blog cache, Redis handles caching across the platform. Destination data, agency profiles, experience listings, and search results all benefit from tagged caching. The advantage of tags: when an agency updates their profile, you invalidate the agency:{id} tag, which clears all cached data related to that specific agency without touching anything else.
One thing to watch, and something we learned the hard way: Laravel’s Redis cache tag implementation can accumulate stale tag entries over time, leading to gradual memory growth. We mitigate this by running periodic cleanup jobs that prune expired tag sets and by monitoring Redis memory usage through Forge’s server monitoring.
Queue Architecture
Several operations run on queues rather than in the HTTP request cycle: embedding generation for the AI chatbot, email notifications (booking confirmations, agency assignments, review requests), Stripe webhook processing, and report generation for the admin panel. Redis serves as both the cache store and the queue driver, which simplifies the infrastructure. Queue workers run as daemon processes managed by Supervisor on the production server.
Deployment and Infrastructure
The platform runs on Hetzner Cloud servers managed through Laravel Forge.
Why Hetzner
We evaluated AWS, DigitalOcean, and Hetzner for this project. Hetzner won. European data residency: with EU-based travelers and GDPR requirements, having servers in Hetzner’s German and Finnish data centers simplifies compliance. Cost-performance ratio: Hetzner’s pricing for equivalent compute resources runs 50-70% lower than DigitalOcean and significantly lower than AWS EC2, according to 2025 benchmarks. And reliability: Hetzner operates three EU data center parks (Falkenstein, Nuremberg, Helsinki) with redundant networking and power.
Laravel Forge for Server Management
Forge handles server provisioning, SSL certificates, database management, queue worker supervision, and deployment automation. Zero-downtime deployments ensure the platform stays live during updates. The deployment script compiles frontend assets, runs any pending migrations, restarts queue workers, and restarts the SSR server. The entire process takes under a minute.
This is the same Hetzner Cloud + Laravel Forge infrastructure stack we use for our managed Laravel hosting service. The combination gives you production-grade server management without the overhead of managing Kubernetes or building custom deployment pipelines.
What We’d Do Differently
Every project teaches you something. Some lessons only become obvious after you’ve shipped.
Start with a formal API contract, even with Inertia. Inertia eliminates the API layer, which is one of its strengths. But on a project this size, documenting the data shape that each controller passes to each component would have prevented some frontend/backend miscommunication during parallel development. We ended up creating TypeScript interfaces partway through. Starting with them would have been better.
Invest in feature flags earlier. We added them midway through development. Having them from day one would have simplified the staging environment and allowed the business team to preview features in production without affecting live users.
The monolith held up. This is the one decision we wouldn’t change. At 35 models and growing, the monolith is still navigable, testable, and deployable in under a minute. The Action pattern kept business logic contained. The domain grouping kept related code together. No regrets on this one.
For teams building complex real-world Laravel applications: start with Actions, invest in your migration strategy early, cache aggressively with tags, and don’t fear the monolith. The added complexity of microservices needs to be justified by actual scaling requirements, not by architecture diagrams that look impressive on a whiteboard.
_Architecture decisions in this article reflect the specific requirements and constraints of this project. Your application’s needs may call for a different approach. The numbers (35 models, 95 migrations, 5 roles) represent the state at launch and will evolve as the platform grows._
Frequently Asked Questions
Why did you choose Laravel 12 over other frameworks for this project?
Laravel’s ecosystem matched the project requirements precisely. Eloquent ORM for complex relational data (35 models with polymorphic relationships), built-in queue system for async operations (AI embeddings, email, webhooks), first-party Inertia.js integration for the SPA-like frontend with SSR, and Forge for deployment automation. The framework’s conventions also meant faster onboarding if the team needed to expand.
Can a Laravel monolith handle a complex marketplace application?
Yes, with proper organization. The key is discipline in code structure. Action classes for business logic, query scopes for data access patterns, policies for authorization, and domain-based file organization keep the codebase manageable even at 35+ models. The monolith becomes a problem when teams are large enough that deployment coordination slows them down. For teams under 10 developers, it’s typically the more productive architecture.
How does the RAG chatbot stay accurate about destinations?
Through retrieval-augmented generation. Destination content written by verified experts is chunked, embedded, and stored in Pinecone. When a user asks a question, the system retrieves the most relevant chunks and includes them in the prompt context. This grounds the AI’s responses in verified data rather than relying on the model’s general training knowledge. When destination content is updated, queue jobs automatically regenerate the affected embeddings.
What are the performance characteristics of this architecture?
With tagged Redis caching, most read-heavy pages serve in under 100ms. The SSR-rendered initial page load provides fast First Contentful Paint for SEO. Queue-based processing keeps HTTP response times low even for operations that involve external APIs (Stripe, OpenAI, Pinecone). The Hetzner Cloud servers with Laravel Forge handle the current traffic comfortably with room for horizontal scaling if needed.
How does headless WordPress work with a Laravel application?
WordPress runs on a subdomain and manages blog content through its standard editor. The Laravel application fetches content through the WordPress REST API and renders it within its own templates and design system. Redis caching with webhook-triggered invalidation ensures content is always fresh without hitting the WordPress API on every request. From the visitor’s perspective, the blog is indistinguishable from the rest of the platform.

