4,000 emails/month for free | Mailtrap sends real emails now!

Build an AI Chat Agent with Laravel 12, MongoDB Atlas Vector Search, and Voyage AI

Last updated on by

Build an AI Chat Agent with Laravel 12, MongoDB Atlas Vector Search, and Voyage AI image

I had a problem last week.

I was staring at the sample_airbnb dataset in my MongoDB Atlas cluster -over 5,500 listings across dozens of cities - and I wanted a way to search it that didn't feel like 2015. Not dropdowns. Not filter checkboxes. Not WHERE property_type = 'Apartment' AND bedrooms >= 2.

I wanted to type: "Find me a cozy place in Porto with a great view" — and get back results that actually understood what I meant.

So I built it. In Laravel. Over a few hours.

This is the story of how Airbnb Arena came together - a RAG-powered chat agent that uses Voyage AI embeddings, MongoDB Atlas Vector Search, and Google Gemini.

But here's the best part: I didn't have to write any complex API plumbing or tool-calling loops myself. I used the new Laravel AI SDK (laravel/ai).

It turns what used to be hundreds of lines of "prompt engineering" and HTTP client code into clean, object-oriented PHP classes.

Let's build it.


What we're building

Here's the flow, end to end:

User → Chat UI → ChatController → AirbnbAgent (Laravel AI Agent)
ListingSearchTool → Voyage AI (via SDK)
MongoDB Atlas $vectorSearch
Matching Listings → Gemini → Response

A user types a question. The AirbnbAgent (powered by Gemini) reads it. If it needs to search, it calls the ListingSearchTool. That tool uses the SDK's Embeddings API to vector-encode the query with Voyage AI, runs a $vectorSearch against MongoDB, and hands the results back. Gemini then synthesizes a natural language answer.

The key pieces:

  • Laravel AI SDK: The glue that connects our code to Gemini and Voyage AI.
  • Voyage AI: Generates vector embeddings for listings and search queries.
  • MongoDB Atlas: Stores data and runs $vectorSearch natively.
  • Google Gemini: The reasoning engine that decides which tools to call.

Why this stack?

MongoDB Atlas is doing double duty. It's both the document store (flexible JSON data) and the vector database. No syncing data between two systems. One cluster, one collection.

Voyage AI offers "retrieval-optimized" embeddings. It distinguishes between documents (what you store) and queries (what users ask), which significantly improves search relevance compared to generic models.

It’s easy to think “any state‑of‑the‑art embedding model will do,” but Voyage‑4 on MongoDB Atlas gives you a few very practical advantages for production RAG and agents.

Shared embedding space & asymmetric retrieval

The entire Voyage‑4 family — voyage‑4‑large, voyage‑4, voyage‑4‑lite, and voyage‑4‑nano — shares a single embedding space. All four models produce compatible embeddings, so you can mix and match models for documents and queries without re‑vectorizing your corpus.

That means you can:

  • Embed your Airbnb listings once with voyage‑4‑large for maximum retrieval quality.
  • Use voyage‑4‑lite (or even voyage‑4‑nano) for query embeddings to keep per‑request latency and cost low.
  • Upgrade query accuracy later by switching to voyage‑4 or voyage‑4‑largewithout touching the stored vectors.

This “asymmetric retrieval” pattern (large model for documents, lighter model for queries) is a perfect fit for Atlas Vector Search: documents change rarely, queries run all the time.

2. Flexible dimensionality & lower vector costs

Voyage‑4 models support multiple embedding dimensionalities (256–2048) via Matryoshka learning and can be quantized (e.g., 8‑bit, binary) with minimal quality loss.

On Atlas, that translates directly into:

  • Smaller index size (fewer dimensions and lower precision).
  • Lower memory and storage footprint for your vector index.
  • The ability to dial in the right accuracy vs. cost/latency tradeoff for each workload.

For something like sample_airbnb, you can comfortably stay in the 512–1024‑dim range and still get excellent retrieval quality.

3. Unified Atlas API, billing, and operations

Through the Embedding and Reranking API on MongoDB Atlas, Voyage AI models are exposed as a native, serverless API inside the Atlas ecosystem — not as “yet another external service” you have to integrate and monitor yourself.

Concretely, you get:

  • Unified billing: Voyage usage is billed through your existing Atlas billing profile, with the same payment method and organization‑level limits.
  • Consolidated monitoring and governance: one place for audit, limits (TPM/RPM), and access control, alongside your databases and Vector Search clusters.
  • Generous free tier: access to the latest Voyage models (including Voyage‑4) with hundreds of millions of free tokens during preview, so you can prototype aggressively before optimizing.

This fits nicely with the Laravel AI SDK story in your article: Atlas becomes the central “AI data plane” (storage + vector search + embeddings + reranking), while Laravel focuses on orchestration and UX.

Laravel AI SDK is one of the game changers here. Instead of manually constructing JSON schemas for tools and parsing LLM responses, you just define a PHP class with a description() method. The SDK handles the rest—serializing parameters, invoking the method, and feeding the result back to the model.


Setting up the foundation

Start fresh:

composer create-project laravel/laravel airbnb-arena
cd airbnb-arena

Install the MongoDB driver and the Laravel AI SDK:

pecl install mongodb
composer require mongodb/laravel-mongodb:5.x-dev laravel/ai

Publish the Laravel AI configuration:

php artisan ai:install

You'll also need a MongoDB Atlas cluster with the Sample Dataset loaded (which gives you the sample_airbnb database).

Configure your .env:

MONGODB_URI=mongodb+srv://user:pass@cluster.mongodb.net/?retryWrites=true&w=majority
MONGODB_DATABASE=sample_airbnb
GEMINI_API_KEY=your-gemini-api-key
VOYAGEAI_API_KEY=your-voyage-ai-key

And update config/ai.php to set your defaults:

// config/ai.php
'default' => 'gemini',
'default_for_embeddings' => 'voyageai',

The Listing Model & Embeddings

First, we need a model that represents our Airbnb listings.

<?php
 
namespace App\Models;
 
use MongoDB\Laravel\Eloquent\Model;
 
class Listing extends Model
{
protected $connection = 'mongodb';
protected $table = 'listingsAndReviews';
 
protected $fillable = [
'name', 'summary', 'description', 'property_type',
'room_type', 'accommodates', 'bedrooms', 'beds',
'price', 'amenities', 'address', 'review_scores',
'embedding', // <-- Vector stored here
];
 
public function toEmbeddingText(): string
{
$market = $this->address['market'] ?? $this->address['country'] ?? '';
 
return implode('. ', array_filter([
$this->name,
$this->summary,
$this->property_type ? "Property type: {$this->property_type}" : null,
$market ? "Location: {$market}" : null,
]));
}
}

Now, let's generate embeddings. We'll create a simple service that wraps the Laravel AI SDK's Embeddings facade. This keeps our code clean and testable.

<?php
 
namespace App\Services;
 
use Laravel\Ai\Embeddings;
 
class EmbeddingService
{
// Voyage-3 uses 1024 dimensions
private int $dimensions = 1024;
 
public function embedMany(array $texts): array
{
// The SDK handles batching and API calls for us
$response = Embeddings::for($texts)
->dimensions($this->dimensions)
->generate(provider: 'voyageai');
 
return $response->embeddings;
}
 
public function embedQuery(string $query): array
{
$response = Embeddings::for([$query])
->dimensions($this->dimensions)
->generate(provider: 'voyageai');
 
return $response->embeddings[0];
}
}

Notice how simple this is? Embeddings::for($texts)->generate(). No cURL requests, no JSON decoding. The SDK abstracts the provider differences away.


Creating the Search Tool

In the old way of doing things, you'd write a massive JSON schema describing your function to the LLM. With Laravel AI, you just write a class that implements Tool.

Here's our ListingSearchTool. The agent uses this to find properties.

 
<?php
 
namespace App\Tools;
 
use App\Models\Listing;
use App\Services\EmbeddingService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
 
/**
* ListingSearchTool — Performs semantic vector search on Airbnb listings.
*
* Implements the Laravel AI SDK Tool interface so the agent can
* automatically invoke it when users ask about listings.
*
* Uses the mongodb/laravel-mongodb Eloquent vectorSearch() method to find
* listings that are semantically similar to the user's natural language query.
* The query is embedded via Voyage AI (through the SDK), then matched
* against pre-computed listing embeddings stored in MongoDB Atlas.
*/
class ListingSearchTool implements Tool
{
/** @var array Structured listing data from the last search */
private array $lastResults = [];
 
public function __construct(
private EmbeddingService $embeddingService
) {}
 
/**
* Describe what this tool does — the agent reads this to decide when to use it.
*/
public function description(): Stringable|string
{
return 'Search for Airbnb listings using semantic vector search. '
. 'Use this to find properties matching a natural language description '
. 'like "cozy apartment in Barcelona with pool" or "family-friendly house near the beach".';
}
 
/**
* Define the parameters the agent can pass to this tool.
*/
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string()
->description('Natural language search query describing the desired listing')
->required(),
'limit' => $schema->integer()
->description('Maximum number of results to return (default: 5)'),
];
}
 
/**
* Get structured listing data from the last search execution.
*/
public function getLastResults(): array
{
return $this->lastResults;
}
 
/**
* Execute the search when the agent invokes this tool.
*/
public function handle(Request $request): Stringable|string
{
$query = (string) $request->string('query');
$limit = $request->integer('limit', 5) ?: 5;
 
Log::info('ListingSearchTool: Searching', ['query' => $query, 'limit' => $limit]);
 
try {
// Step 1: Generate a query embedding using Voyage AI (via SDK)
$queryEmbedding = $this->embeddingService->embedQuery($query);
 
// Step 2: Use the Eloquent vectorSearch() method from mongodb/laravel-mongodb
// Materialize results into a collection once to avoid double cursor iteration
$results = Listing::vectorSearch(
index: 'vector_index',
path: 'embedding',
queryVector: $queryEmbedding,
numCandidates: $limit * 10,
limit: $limit,
);
 
// Step 3: Single-pass — build both structured frontend data and agent text
$agentLines = [];
$this->lastResults = [];
 
foreach ($results as $index => $listing) {
$address = (array) ($listing->address ?? []);
$reviewScores = (array) ($listing->review_scores ?? []);
$images = (array) ($listing->images ?? []);
$price = $listing->price;
if (is_object($price)) {
$price = (string) $price;
}
$score = round(($listing->vector_search_score ?? 0) * 100, 1);
$location = $address['market'] ?? $address['country'] ?? 'Unknown';
$rating = $reviewScores['review_scores_rating'] ?? 'N/A';
$summary = $listing->summary ?? '';
 
// Structured data for the frontend
$this->lastResults[] = [
'id' => (string) $listing->_id,
'name' => $listing->name ?? 'Unnamed',
'summary' => $summary,
'property_type' => $listing->property_type ?? 'Property',
'room_type' => $listing->room_type ?? '',
'accommodates' => $listing->accommodates ?? null,
'bedrooms' => $listing->bedrooms ?? null,
'beds' => $listing->beds ?? null,
'bathrooms' => isset($listing->bathrooms) ? (string) $listing->bathrooms : null,
'price' => $price,
'location' => $location,
'country' => $address['country'] ?? '',
'street' => $address['street'] ?? '',
'image_url' => $images['picture_url'] ?? null,
'rating' => $reviewScores['review_scores_rating'] ?? null,
'cleanliness' => $reviewScores['review_scores_cleanliness'] ?? null,
'score' => $score,
];
 
// Formatted text for the agent/LLM
$agentLines[] = sprintf(
"%d. **%s** (ID: %s)\n 📍 %s | 🏠 %s | 👥 %s guests | 🛏️ %s beds\n ⭐ Rating: %s/100 | 💰 $%s/night | 🎯 Match: %s%%\n %s",
$index + 1,
$listing->name ?? 'Unnamed',
(string) $listing->_id,
$location,
$listing->property_type ?? 'Property',
$listing->accommodates ?? '?',
$listing->beds ?? '?',
$rating,
$price ?? '?',
$score,
$summary ? mb_substr($summary, 0, 150) . '...' : 'No description'
);
}
 
if (empty($agentLines)) {
return "No listings found matching your search. Try a different query.";
}
 
$count = count($agentLines);
return "Found {$count} matching listings:\n\n" . implode("\n\n", $agentLines);
 
} catch (\Exception $e) {
Log::error('ListingSearchTool error', ['error' => $e->getMessage()]);
return "Error searching listings: {$e->getMessage()}";
}
}
}

This is the power of the SDK. We define the schema in PHP and the execution logic in the same class. Dependency injection works automatically (EmbeddingService is injected).


The Agent

Now we bring it all together with an Agent. An Agent is essentially a "persona" with a specific set of tools and instructions.

<?php
 
namespace App\Agents;
 
use App\Tools\ListingDetailsTool;
use App\Tools\ListingSearchTool;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
 
/**
* AirbnbAgent — The AI-powered travel concierge for Airbnb Arena.
*
* Implements the Laravel AI SDK Agent interface with tool-calling support.
* The agent uses Gemini as its LLM provider and has access to two tools:
*
* - ListingSearchTool: Semantic vector search via MongoDB Atlas $vectorSearch
* - ListingDetailsTool: Fetch full listing details by MongoDB document ID
*
* The SDK handles the entire tool-calling loop automatically:
* 1. Send user message to Gemini with tool definitions
* 2. If Gemini requests a tool call, execute it
* 3. Send tool results back to Gemini
* 4. Repeat until Gemini returns a final text response
*/
#[MaxSteps(10)]
#[Timeout(120)]
class AirbnbAgent implements Agent, HasTools
{
use Promptable;
 
/**
* The system prompt that defines the agent's personality and rules.
*/
public function instructions(): string
{
return <<< PROMPT
You are the **Airbnb Arena Host** — an enthusiastic, knowledgeable travel concierge AI powered by MongoDB Atlas Vector Search and Voyage AI embeddings.
 
Your role:
- Help users find the perfect Airbnb listing from the sample_airbnb dataset
- Use the search_listings tool to find properties matching user descriptions
- Use the get_listing_details tool when users want more info about a specific listing
- Compare listings when users are deciding between options
- Provide personalized recommendations based on preferences
 
Personality:
- Friendly and enthusiastic about travel 🌍
- Knowledgeable about different neighborhoods and property types
- Helpful with practical advice (best for families, couples, budget, luxury, etc.)
- Use emojis sparingly but effectively
- Format responses with markdown for readability
 
Important rules:
- Always use the search tool to find real listings — never make up properties
- When showing search results, highlight the key differentiators
- If the user's query is vague, ask clarifying questions
- Mention the listing IDs so users can ask for more details
PROMPT;
}
 
/**
* The tools available to this agent.
* The SDK automatically generates the JSON schema for Gemini
* from each tool's description() and schema() methods.
*/
public function tools(): iterable
{
return [
app(ListingSearchTool::class),
new ListingDetailsTool(),
];
}
}

That's it. No while loops. No checking finish_reason. No parsing JSON tool calls. The SDK handles the entire "Reasoning Loop":

  1. Send user input and tools definition to Gemini.
  2. Gemini responds with a tool call request.
  3. SDK executes the tool.
  4. SDK sends the tool output back to Gemini.
  5. Gemini generates the final answer.

The Controller

Finally, we expose this to the frontend.

<?php
 
namespace App\Http\Controllers;
 
use App\Agents\AirbnbAgent;
use App\Tools\ListingSearchTool;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
 
/**
* ChatController — Handles the Airbnb Arena chat interface.
*
* Uses the Laravel AI SDK's Agent pattern to connect users to Gemini.
* The SDK handles tool-calling automatically — when Gemini decides
* it needs to search or fetch details, the SDK executes the tools
* and feeds results back to the model.
*
* Architecture:
* 1. User sends a message
* 2. AirbnbAgent (via SDK) sends it to Gemini with tool definitions
* 3. Gemini may call tools (search/details) — SDK executes them
* 4. SDK sends tool results back to Gemini for final response
* 5. Return the formatted response to the user
*/
class ChatController extends Controller
{
/**
* Show the chat UI.
*/
public function index()
{
return view('arena');
}
 
/**
* Handle a chat message from the user.
*/
public function chat(Request $request)
{
// Allow up to 2 minutes for the agent loop (embedding + vector search + LLM reasoning)
set_time_limit(120);
 
$request->validate([
'message' => 'required|string|max:2000',
'history' => 'array',
]);
 
$userMessage = $request->input('message');
$history = $request->input('history', []);
 
try {
// Build the prompt with conversation history for context
$prompt = $this->buildPrompt($userMessage, $history);
 
// Create the agent (via make() for dependency injection) and send the prompt
$agent = AirbnbAgent::make();
$response = $agent->prompt($prompt, provider: 'gemini');
 
// Collect any structured listing data from the search tool
$searchTool = app(ListingSearchTool::class);
$listings = $searchTool->getLastResults();
 
return response()->json([
'reply' => (string) $response,
'listings' => $listings,
'success' => true,
]);
} catch (\Exception $e) {
Log::error('ChatController error', ['error' => $e->getMessage()]);
return response()->json([
'reply' => "Sorry, I encountered an error: {$e->getMessage()}",
'success' => false,
], 500);
}
}
 
/**
* Build the prompt string with conversation history for context.
* The agent's instructions (system prompt) are defined in AirbnbAgent.
*/
private function buildPrompt(string $userMessage, array $history): string
{
if (empty($history)) {
return $userMessage;
}
 
// Include recent conversation history so the agent understands context
// (e.g., "Tell me more about the second one" refers to previous results)
$context = collect($history)->map(function ($msg) {
$role = $msg['role'] === 'user' ? 'User' : 'Assistant';
return "{$role}: {$msg['content']}";
})->implode("\n\n");
 
return "{$context}\n\nUser: {$userMessage}";
}
}

It's deceptively simple. $agent->prompt($message) does all the heavy lifting of the RAG pipeline.


Wrapping Up

We built a full RAG agent with vector search, semantic understanding, and autonomous tool use.

The Laravel AI SDK drastically simplifies building these kinds of applications. By standardizing Agents and Tools, it lets us focus on the business logic (the search query, the result formatting) rather than the plumbing of communicating with LLMs.

Combined with MongoDB Atlas for data+vectors and Voyage AI for high-quality embeddings, you have a production-ready stack for intelligent search.

The full source code is available on GitHub. Happy coding!

Pavel Duchovny photo

Lead Developer Advocate at MongoDB

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
SerpApi

The Web Search API for Your LLM and AI Applications

Visit SerpApi
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
PhpStorm logo

PhpStorm

The go-to PHP IDE with extensive out-of-the-box support for Laravel and its ecosystem.

PhpStorm
Laravel Cloud logo

Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Cloud
Acquaint Softtech logo

Acquaint Softtech

Acquaint Softtech offers AI-ready Laravel developers who onboard in 48 hours at $3000/Month with no lengthy sales process and a 100 percent money-back guarantee.

Acquaint Softtech
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit

The latest

View all →
Axios npm Package Compromised With Remote Access Trojan image

Axios npm Package Compromised With Remote Access Trojan

Read article
Build an AI Chat Agent with Laravel 12, MongoDB Atlas Vector Search, and Voyage AI image

Build an AI Chat Agent with Laravel 12, MongoDB Atlas Vector Search, and Voyage AI

Read article
PHP Debugger: A Lightweight Xdebug Alternative Built for Speed image

PHP Debugger: A Lightweight Xdebug Alternative Built for Speed

Read article
Laravel USPS: A Modern Wrapper for the USPS API image

Laravel USPS: A Modern Wrapper for the USPS API

Read article
Debugbar releases v4.2.0 and add a new Boost skill image

Debugbar releases v4.2.0 and add a new Boost skill

Read article
New Expressive Model Attributes in Laravel 13.2.0 image

New Expressive Model Attributes in Laravel 13.2.0

Read article