ELOQUENT AI
Talk to your database in plain English.
No SQL. No BI tool. Zero config to start.
A plug-and-play Laravel package that lets authorized users query their database through a floating chat interface — no SQL knowledge required. Works with or without AI providers. Rich card views, CSV/JSON export, smart suggestions, temporal queries, index advisory, and a fully customizable config — all out of the box.
Every team has questions about their data. Most solutions are painful:
- BI tools (Metabase, Tableau) — require separate infra, licensing, and training
- Raw SQL — only works for engineers, never business users
- Generic AI chatbots — no knowledge of your schema, no security controls, hallucinate queries
- Spreadsheet exports — stale data the moment you export it
Eloquent AI embeds a secure, schema-aware chat interface directly into your Laravel app — deployed in 3 commands, authorized via your existing gate system, and configurable to your domain vocabulary.
Without Eloquent AI With Eloquent AI
┌──────────────────────┐ ┌─────────────────────────────┐
│ Business user asks │ │ Business user types: │
│ engineer for a query │ │ "Show me pending orders │
│ → engineer writes │ │ from last month" │
│ SQL or exports │ │ → Eloquent AI resolves, │
│ CSV │ │ secures, and executes │
│ → stale by the │ │ instantly in the app │
│ time it lands │ │ → Rich cards, export, │
│ │ │ navigation links │
└──────────────────────┘ └─────────────────────────────┘
Hours per query Seconds, self-serve
| Feature | Eloquent AI | Raw SQL | Metabase / BI | Generic AI Chat |
|---|---|---|---|---|
| No SQL required | ✅ | ❌ | ||
| Zero external infra | ✅ | ✅ | ❌ Separate server | ❌ API only |
| Works without AI provider | ✅ | ✅ | ✅ | ❌ |
| Schema-aware queries | ✅ | Manual | ✅ | ❌ |
| 7-layer security | ✅ | Manual | ❌ | |
| Horizon-style access gate | ✅ | N/A | ❌ | ❌ |
| Domain vocabulary mapping | ✅ | Manual | ❌ | |
| Rich card layouts | ✅ | ❌ | ✅ | ❌ |
| Inline CSV / JSON export | ✅ | Manual | ✅ | ❌ |
| Real-time (live DB) | ✅ | ✅ | ❌ | |
| Audit trail | ✅ | Manual | ❌ | |
| Embeds in your app | ✅ | N/A | ❌ iframe only | ❌ |
| Temporal queries ("last month") | ✅ | Manual | ✅ | |
| Index advisory | ✅ | Manual | ❌ | ❌ |
- Requirements
- Installation
- Quick Start
- Configuration Reference
- Publishing Assets
- Using the Chat Interface
- Database Dashboard & FAQs
- Export System
- The HasEloquentAi Trait
- Custom Query Handlers
- Artisan Commands
- Programmatic Usage (Facade)
- How It Works
- Query Examples
- Security
- Performance & Index Advisor
- Plugins
- Events
- Testing
- Troubleshooting
- Roadmap
- Full File Reference
| Dependency | Version | Required? |
|---|---|---|
| PHP | ^8.3 | Yes |
| Laravel | ^12.0 / ^13.0 | Yes |
| laravel/ai | ^0.x | No — only for ai / auto engine modes |
The package is fully functional without any AI provider using the built-in local engine mode.
composer require ubxty/eloquent-aiLocal development (path repository):
{
"repositories": [
{
"type": "path",
"url": "packages/ubxty/eloquent-ai"
}
]
}composer require ubxty/eloquent-ai:@devphp artisan eloquent-ai:install --no-interactionThis will:
- Publish config files (
config/eloquent-ai.php,config/eloquent-ai-routes.php) - Publish and run database migrations (audit logs + conversations tables)
- Scan your schema (models, relationships, columns)
- Scan your routes (auto-generate route-to-model mappings)
By default, any authenticated user can access Eloquent AI. To restrict to specific users, use the Horizon-style Gate (recommended) or config-based access control:
// app/Providers/AppServiceProvider.php
use Ubxty\EloquentAi\EloquentAiFacade as EloquentAi;
public function boot(): void
{
EloquentAi::auth(function ($user) {
return $user->hasRole('ADMIN');
// or: return $user->account_type === 'ADMIN';
// or: return in_array($user->email, ['admin@example.com']);
});
}Vue 3 SPA — add to your layout component:
<template>
<router-view />
<EloquentAiChat />
</template>
<script>
import EloquentAiChat from 'vendor/ubxty/eloquent-ai/resources/js/EloquentAiChat.vue';
// Or after publishing: import EloquentAiChat from './vendor/eloquent-ai/EloquentAiChat.vue';
export default { components: { EloquentAiChat } };
</script>Blade — add the directive to any layout:
@eloquentAiChatOr visit /eloquent-ai for the standalone full-page chat.
# Install
php artisan eloquent-ai:install --no-interaction
# Set engine to local (no AI needed)
# .env:
ELOQUENT_AI_ENGINE=local
# Visit /eloquent-ai and try:
# "How many users are there?"
# "Show me the latest 5 orders"
# "Count orders grouped by status"
# "Users created this month"
# "Export users as CSV"Publish the config:
php artisan eloquent-ai:publish --configThis creates config/eloquent-ai.php — the single file that controls the entire package behavior. Every section is documented below.
// .env
ELOQUENT_AI_ENGINE=auto| Mode | Description | AI Required? |
|---|---|---|
local |
Built-in NaturalLanguage regex engine only. Zero external API calls. | No |
ai |
Always sends queries to the AI provider (via laravel/ai). |
Yes |
auto |
Tries local first, falls back to ai for complex queries. |
Optional |
'schema' => [
// How models are discovered
'discovery_mode' => 'all', // 'all', 'trait', or 'mixed'
// Directories to scan for Eloquent models
'model_directories' => [app_path('Models')],
// Only allow these models (empty = allow all discovered)
'include_models' => [],
// Never expose these models to the chat
'exclude_models' => [
App\Models\PersonalAccessToken::class,
],
// Tables completely hidden from all queries
'hidden_tables' => [
'migrations', 'password_reset_tokens',
'telescope_entries', 'telescope_entries_tags',
'failed_jobs', 'cache', 'cache_locks',
],
// Columns redacted — never returned in results or shown in schema
'redacted_columns' => [
'password', 'remember_token',
'two_factor_secret', 'google2fa_secret',
],
// Schema cache duration (seconds). 0 = no caching.
'cache_ttl' => 3600,
],| Discovery Mode | Description |
|---|---|
all (default) |
Scans directories, indexes every Eloquent model. No code changes needed. |
trait |
Only indexes models with use HasEloquentAi. Explicit opt-in. |
mixed |
Scans all models. Trait-using models get priority and per-model config. |
The package supports a 5-step authorization cascade, evaluated in order:
'access' => [
// Middleware for page/view routes
'middleware' => ['web', 'auth'],
// Separate middleware for API routes (POST /ask, DELETE, etc.)
// Falls back to 'middleware' if not set
'api_middleware' => ['web', 'auth'],
// Skip CSRF verification on API endpoints (recommended for SPA)
'csrf_exempt' => true,
// Laravel Gate name
'gate' => 'use-eloquent-ai',
// Spatie permission roles
'roles' => ['ADMIN'],
// Specific user IDs
'allowed_users' => [],
],Authorization cascade:
| Priority | Method | How |
|---|---|---|
| 1 | EloquentAi::auth() |
Horizon-style callback (highest priority) |
| 2 | Gate | Gate::allows('use-eloquent-ai', $user) |
| 3 | Allowed users | in_array($user->id, config('eloquent-ai.access.allowed_users')) |
| 4 | Roles | $user->hasRole(config('eloquent-ai.access.roles')) (Spatie) |
| 5 | Allow all | If nothing is configured, any authenticated user can access |
Horizon-style Gate (recommended):
// app/Providers/AppServiceProvider.php
use Ubxty\EloquentAi\EloquentAiFacade as EloquentAi;
public function boot(): void
{
EloquentAi::auth(function ($user) {
return $user->hasRole('ADMIN');
});
}Auth strategies for different setups:
| Strategy | Config |
|---|---|
| Session-based (Blade/Vue SPA) | middleware: ['web', 'auth'] |
| Sanctum SPA cookies | api_middleware: ['web', 'auth:sanctum'] |
| Sanctum API tokens | api_middleware: ['api', 'auth:sanctum'] + bearerToken prop |
| Passport | api_middleware: ['api', 'auth:api'] + bearerToken prop |
'query' => [
'max_rows' => 100, // Max records per query
'max_execution_time' => 10, // Seconds (gracefully skipped if DB doesn't support)
'allow_aggregates' => true, // COUNT, SUM, AVG, MIN, MAX
'allow_subqueries' => false, // Nested queries
'allow_full_table_scan' => false, // SELECT * without WHERE (aggregates exempt)
'allow_write_operations' => false, // INSERT, UPDATE, DELETE (always false = safety)
'eager_load_depth' => 2, // Max relationship nesting depth
'complexity_score_limit' => 50, // Reject overly complex query plans
],This is where the package becomes truly powerful — you can map your entire domain vocabulary so users can query using business terms, not database column names.
Map how users refer to your tables:
'natural_language' => [
'model_aliases' => [
'staff' => App\Models\User::class,
'employees' => App\Models\User::class,
'clients' => App\Models\Customer::class,
'tickets' => App\Models\SupportTicket::class,
'docs' => App\Models\Document::class, // "docs" = documents
],
],Now "How many docs do we have?" queries the documents table.
Go beyond simple aliases — define display names, priorities, hidden columns, and example queries per model:
'natural_language' => [
'models' => [
App\Models\User::class => [
'display_name' => 'User',
'aliases' => ['staff', 'employee', 'team member', 'member'],
'priority' => 100, // Higher = shown first in suggestions
'hidden_columns' => ['password', 'remember_token', 'two_factor_secret'],
'example_queries' => [
'How many users are active?',
'Show me the latest 10 users',
'Count users by account type',
],
],
App\Models\Order::class => [
'display_name' => 'Order',
'aliases' => ['orders', 'purchases', 'transactions'],
'priority' => 90,
'example_queries' => [
'How many orders are there?',
'Show latest 5 orders',
'Count orders by status',
],
],
App\Models\Product::class => [
'display_name' => 'Product',
'aliases' => ['products', 'items', 'catalog', 'listings'],
'priority' => 80,
'example_queries' => [
'How many products are there?',
'Show products where status is active',
],
],
],
],Map business terms to actual database columns, per model:
'column_aliases' => [
App\Models\User::class => [
'role' => 'account_type',
'phone' => 'contact_phone',
'joined' => 'created_at',
],
App\Models\Location::class => [
'address' => 'street_address',
'city' => 'address_city',
'state' => 'address_state',
'zip' => 'postal_code',
'lat' => 'latitude',
'lng' => 'longitude',
],
],Now "Show locations in city Denver" works — city resolves to address_city.
Map business keywords to predefined WHERE clauses — no column knowledge needed by the end user:
'keyword_filters' => [
App\Models\User::class => [
'active' => [['field' => 'active', 'operator' => '=', 'value' => 1]],
'inactive' => [['field' => 'active', 'operator' => '=', 'value' => 0]],
'admins' => [['field' => 'account_type', 'operator' => '=', 'value' => 'admin']],
],
App\Models\Order::class => [
'approved' => [['field' => 'status', 'operator' => '=', 'value' => 'approved']],
'rejected' => [['field' => 'status', 'operator' => '=', 'value' => 'rejected']],
'in progress' => [['field' => 'status', 'operator' => '=', 'value' => 'in_progress']],
'pending' => [['field' => 'status', 'operator' => '=', 'value' => 'pending']],
'recent' => [['field' => 'created_at', 'operator' => '>=', 'value' => '-30 days']],
],
App\Models\Product::class => [
'active' => [['field' => 'active', 'operator' => '=', 'value' => 1]],
'low_stock' => [['field' => 'stock_quantity', 'operator' => '<=', 'value' => 10]],
],
],Now "How many active users?" → User::where('active', 1)->count().
"Show me recent reports" → Report::where('created_at', '>=', 30 days ago)->get().
Relative dates like -30 days are resolved at query time.
Computed columns that don't exist in the database — defined as SQL expressions, model scopes, or PHP callbacks:
'virtual_columns' => [
App\Models\User::class => [
'initials' => [
'type' => 'expression',
'expression' => "UPPER(CONCAT(LEFT(first_name, 1), LEFT(last_name, 1)))",
'description' => 'User initials from first and last name',
],
'full_name' => [
'type' => 'expression',
'expression' => "CONCAT(first_name, ' ', last_name)",
'description' => 'Combined first and last name',
],
],
App\Models\Location::class => [
'nearby' => [
'type' => 'scope',
'scope' => 'nearCoordinates',
'description' => 'Filter locations by proximity to coordinates',
],
],
],Now "Find user with initials RJ" → User::whereRaw("UPPER(CONCAT(LEFT(first_name, 1), LEFT(last_name, 1))) = ?", ['RJ'])->get().
Virtual column types:
| Type | How it works |
|---|---|
expression |
Raw SQL via whereRaw() with parameterized binding |
scope |
Calls model scope (e.g. ->nearLocation($value)) |
callback |
Custom PHP closure fn($query, $value) => ... |
By default, the package auto-hides technical database noise from chat responses:
'response' => [
'field_visibility' => [
'hide_uuid_fields' => true, // Auto-detect & hide UUID-valued columns
'hide_foreign_keys' => true, // Hide *_id, *_uuid columns
'hide_timestamp_fields' => false, // created_at, updated_at (shown by default)
'hide_soft_delete_fields' => true, // deleted_at
'always_show' => [], // Force show these columns regardless
'always_hide' => [], // Force hide these columns regardless
// Per-model overrides
'per_model' => [
App\Models\User::class => [
'hide' => ['google2fa_secret', 'fa2_enabled'],
'show' => ['name', 'email', 'account_type'],
],
],
],
],How UUID detection works: If a column value matches the UUID regex (/^[0-9a-f]{8}-[0-9a-f]{4}-/i), the column is automatically hidden — including the id column when it's a UUID primary key. Numeric auto-increment IDs (like id: 42) are kept because they're meaningful references.
Transform raw database values into rich, interactive displays:
'response' => [
'field_renderers' => [
// Global renderers (apply to all models)
'email' => ['type' => 'email'], // → mailto: link
'phone' => ['type' => 'phone'], // → tel: link
'status' => ['type' => 'status', 'options' => [
'colors' => [
'active' => 'green',
'approved' => 'green',
'pending' => 'yellow',
'in_progress' => 'blue',
'rejected' => 'red',
'inactive' => 'gray',
],
]],
'is_active' => ['type' => 'boolean', 'options' => [
'true_label' => 'Active',
'false_label' => 'Inactive',
]],
],
// Per-model renderers
'field_renderers_per_model' => [
App\Models\Location::class => [
'latitude' => ['type' => 'coordinates', 'options' => [
'lng_field' => 'longitude',
'label' => 'View on Map',
]],
'address' => ['type' => 'address'],
],
App\Models\Employee::class => [
'current_latitude' => ['type' => 'coordinates', 'options' => [
'lng_field' => 'current_longitude',
'label' => 'View Location',
]],
],
],
],Available renderer types:
| Type | Output | Example |
|---|---|---|
coordinates |
Google Maps link | 📍 View on Map → https://maps.google.com/?q=40.7,-74.0 |
address |
Google Maps search link | 🗺️ View Map → https://maps.google.com/?q=123+Main+St |
email |
Clickable mailto link | 📧 user@example.com |
phone |
Clickable tel link | 📞 (555) 123-4567 |
status |
Color-coded badge | 🟢 Active, 🔴 Rejected, 🟡 Pending |
boolean |
Human-readable label | ✅ Active / ❌ Inactive |
currency |
Formatted money | $1,234.56 |
percentage |
Percentage format | 85.5% |
date_relative |
Relative timestamp | 3 days ago |
filesize |
Human-readable size | 2.4 MB |
json |
Pretty-printed JSON | Expandable JSON block |
truncate |
Truncated with "..." | First 100 chars + "..." |
url |
Clickable link | 🔗 Open Link |
custom |
Custom formatting | Your own callback |
Replace plain tables with rich, visually structured cards for specific models:
'response' => [
'card_auto_detect' => true, // Auto-generate cards from common fields
'card_layouts' => [
App\Models\User::class => [
'title' => 'name',
'subtitle' => '{email}',
'badges' => [
['field' => 'account_type', 'colors' => [
'1' => '#e74c3c', '2' => '#3498db', '3' => '#2ecc71',
]],
['field' => 'active', 'colors' => [
'1' => '#2ecc71', '0' => '#95a5a6',
]],
],
'details' => ['phone', 'created_at'],
'accent_color' => '#667eea',
],
App\Models\Product::class => [
'title' => 'name',
'subtitle' => '{category}, {sku}',
'badges' => [
['field' => 'active', 'colors' => ['1' => '#2ecc71', '0' => '#e74c3c']],
],
'details' => ['price', 'stock_quantity'],
'actions' => [
['label' => 'View Product', 'url' => '/products/{id}'],
],
'accent_color' => '#2ecc71',
],
],
],Card layout options:
| Option | Type | Description |
|---|---|---|
title |
string | Field name for the card heading |
subtitle |
string | Field name or template with {field} placeholders |
badges |
array | Pill-shaped highlights with optional color maps |
details |
array | Key/value pairs in the card body (rich fields supported) |
actions |
array | Buttons with template URLs {field} |
accent_color |
string/map | Static #hex or field-based color map for left border |
avatar |
string | Field name containing avatar/image URL |
image |
string | Field name containing a display image URL |
custom_view |
string | Fully custom Vue/Blade component name |
Display modes (auto-selected):
| Mode | When |
|---|---|
table |
No card layout configured, or aggregate results |
cards |
Multiple records with a card layout |
single-card |
One record with a card layout |
custom |
custom_view is specified in the card layout |
When card_auto_detect is true and no explicit layout exists, the package examines the model's columns for common field names (name, title, email, status, created_at) and auto-generates an appropriate card layout.
For full control, point a model to a custom Vue component or Blade partial:
'card_layouts' => [
App\Models\Order::class => [
'custom_view' => 'order-detail-card', // Vue component name or Blade partial
],
],Publish the Vue stubs to customize:
php artisan eloquent-ai:publish --vueMap the chat's "export" command to your existing export controllers/routes:
'export' => [
'enabled' => true,
// Allow inline CSV/JSON generation from query results
'allow_inline' => true,
// Supported formats
'formats' => ['csv', 'json'],
// Max rows for inline export (protects memory)
'max_inline_rows' => 5000,
// Map models to existing export endpoints
'mapped_exports' => [
App\Models\Order::class => [
'controller' => 'App\\Http\\Controllers\\Export\\OrderExportController',
'method' => 'exportOrders',
'route_name' => 'export.orders',
'formats' => ['csv', 'xlsx', 'pdf'],
'params' => ['status', 'date_from', 'date_to'],
'description' => 'Export orders with filters',
'frontend_url' => '/orders?export=true',
],
App\Models\User::class => [
'controller' => 'App\\Http\\Controllers\\Export\\UserExportController',
'method' => 'exportUsers',
'route_name' => 'export.users',
'formats' => ['csv', 'xlsx'],
'params' => ['role', 'active'],
'description' => 'Export user data',
],
],
],How export works:
- User says "export users as CSV" or "export this as CSV" (after viewing results)
- If the model has a
mapped_exportsentry → returns the existing export URL + page link - If no mapping exists and
allow_inlineis true → generates CSV/JSON inline from last query results - If export is disabled → returns an error message
'rate_limit' => [
'enabled' => true,
'max_queries_per_minute' => 20,
'max_queries_per_hour' => 200,
'max_queries_per_day' => 1000,
],Every query is logged with the user, timestamp, query plan, execution time, and optional results:
'audit' => [
'enabled' => true,
'channel' => 'stack',
'log_queries' => true,
'log_ai_responses' => true,
'log_results' => false, // Be careful with PII
'retention_days' => 90,
],Prune old entries:
php artisan eloquent-ai:audit-prune --days=90The package scans your Laravel routes and generates clickable navigation links in query results:
'routes' => [
'enabled' => true,
'auto_discover' => true,
'config_file' => config_path('eloquent-ai-routes.php'),
'include_patterns' => ['*'],
'exclude_patterns' => ['telescope/*', 'horizon/*', 'eloquent-ai/*'],
'frontend_base_url' => env('ELOQUENT_AI_FRONTEND_URL'),
'url_format' => 'laravel', // 'laravel', 'vue-router', 'inertia'
],After scanning (php artisan eloquent-ai:scan-routes), results are cached in config/eloquent-ai-routes.php. Edit this file to fine-tune route-to-model mappings — the chat will then generate clickable links to relevant records in your app.
'multi_tenancy' => [
'enabled' => false,
'strategy' => 'column', // 'column' or 'database'
'tenant_column' => 'team_id',
'tenant_resolver' => null, // Custom resolver class
],Granular control over published assets:
php artisan eloquent-ai:publish --config # Config files only
php artisan eloquent-ai:publish --migrations # Migration files only
php artisan eloquent-ai:publish --views # Blade views (customize chat page)
php artisan eloquent-ai:publish --vue # Vue 3 components (customize widget)
php artisan eloquent-ai:publish --css # CSS styles
php artisan eloquent-ai:publish --all # Everything
php artisan eloquent-ai:publish --all --force # Overwrite existing| Asset | Published To |
|---|---|
| Config | config/eloquent-ai.php, config/eloquent-ai-routes.php |
| Migrations | database/migrations/ |
| Blade Views | resources/views/vendor/eloquent-ai/ |
| Vue Widget | resources/js/vendor/eloquent-ai/ |
| CSS | resources/css/vendor/eloquent-ai/ |
Navigate to /eloquent-ai — a standalone dark-themed chat page served automatically behind the access gate.
Add the floating chat widget to any Blade layout:
{{-- In your layout file --}}
@eloquentAiChatThis renders a self-contained floating chat bubble with all JS/CSS inline — no build step needed. Only visible to authorized users (respects the access gate).
Import into your Vue 3 app:
<template>
<router-view />
<EloquentAiChat
base-url="/eloquent-ai"
admin-check="IS_ADMIN"
user-cookie="login_user"
/>
</template>
<script>
import EloquentAiChat from 'vendor/ubxty/eloquent-ai/resources/js/EloquentAiChat.vue';
// Or after publishing: import EloquentAiChat from './vendor/eloquent-ai/EloquentAiChat.vue';
export default { components: { EloquentAiChat } };
</script>Props:
| Prop | Type | Default | Description |
|---|---|---|---|
baseUrl |
String | /eloquent-ai |
API endpoint prefix |
adminCheck |
String / Function / null | 'IS_ADMIN' |
Cookie property or function (user) => bool. null = show to all. |
userCookie |
String | login_user |
Cookie containing JSON user data |
position |
String | bottom-right |
bottom-right or bottom-left |
bearerToken |
String / null | null |
API token for Sanctum/Passport auth (adds Authorization: Bearer <token> header) |
Custom admin check:
<EloquentAiChat :admin-check="(user) => user.account_type === 'ADMIN' || user.IS_SUB_ADMIN" />API token auth (for non-cookie setups):
<EloquentAiChat bearer-token="your-api-token" :admin-check="null" />Features:
- Server-side authorization check via
/eloquent-ai/auth-checkendpoint - Sends its own CSRF token (doesn't inherit host app's axios config)
- Rich card rendering, export buttons, navigation links
- Dashboard link in the header
- Persistent conversation across page navigations
Use the REST API directly from any frontend (React, Angular, mobile, etc.):
POST /eloquent-ai/ask Ask a question
POST /eloquent-ai/ask {"question":"export..."} Export data
GET /eloquent-ai/suggest?q=show+me Get autocomplete suggestions
GET /eloquent-ai/auth-check Check if current user is authorized
GET /eloquent-ai/schema Get schema info + stats
GET /eloquent-ai/dashboard Get dashboard data (model cards, FAQs, quick queries)
GET /eloquent-ai/index-advisor Get index suggestions
GET /eloquent-ai/stream?question=&conversation_id= SSE streaming
GET /eloquent-ai/conversations/{id} Get conversation history
DELETE /eloquent-ai/conversations/{id} Clear conversation
GET /eloquent-ai/export/download?file= Download inline export file
GET /eloquent-ai/dashboard/view Standalone dashboard page (Blade)
Example:
const response = await fetch('/eloquent-ai/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: 'How many users signed up this month?',
conversation_id: null,
}),
});
const { success, data, conversation_id } = await response.json();
// data.records = [{ count: 42 }]
// data.meta = { explanation, total_rows, execution_time_ms, model, display }
// data.meta.display = { mode: 'table' | 'cards' | 'single-card', cards: [...] }
// data.meta.export = { type, url, filename } (for export responses)
// data.follow_up_suggestions = ["Show users by day", "List new users"]
// data.navigation_links = [{ url: '/users/1', label: 'View User #1' }]The package includes a built-in dashboard at /eloquent-ai/dashboard/view (or JSON at /eloquent-ai/dashboard) that provides:
- Summary stats — total models, tables, fields, relationships
- Model cards — each model with display name, table, key fields, relationships, scopes
- Auto-generated FAQs — "What is the Users table?", "How many orders?", "Which tables support soft deletes?"
- Quick query cards — clickable examples that navigate to the chat with pre-filled queries
- Index suggestions — top performance recommendations
The dashboard link appears in the chat widget header (database icon). All dashboard routes are behind the same access gate.
Ask the chat to export results:
"Export users as CSV"
"Export this as JSON" ← exports the results from the previous query
"Download sites as CSV"
The package generates a temporary file and returns a download link. Inline exports are limited to export.max_inline_rows (default: 5000).
For models with configured mapped_exports, the chat returns:
- A link to the existing export endpoint/controller
- A link to the frontend export page (if
frontend_urlis configured) - Available filter parameters
"Export reports as CSV"
→ "Reports can be exported from the existing export page: /reports?export=true
Available filters: status, date_from, date_to, company_id"
Add use HasEloquentAi to any model for fine-grained per-model control:
use Ubxty\EloquentAi\Traits\HasEloquentAi;
class Order extends Model
{
use HasEloquentAi;
public static function eloquentAiLabel(): string { return 'Purchase Order'; }
public static function eloquentAiAliases(): array { return ['PO', 'purchase']; }
public static function eloquentAiHiddenColumns(): array { return ['internal_notes']; }
public static function eloquentAiRelationships(): ?array { return ['customer', 'items']; }
public static function eloquentAiScopes(): ?array { return ['active', 'recent']; }
public static function eloquentAiBaseQuery(): ?\Closure
{
return fn ($q) => $q->where('tenant_id', auth()->user()->tenant_id);
}
public static function eloquentAiMaxRows(): ?int { return 500; }
}| Method | Default | Purpose |
|---|---|---|
eloquentAiLabel() |
Class basename | Display name in chat |
eloquentAiAliases() |
[] |
Extra aliases users can type |
eloquentAiHiddenColumns() |
[] |
Columns hidden for this model |
eloquentAiRelationships() |
null (auto-discover) |
Whitelist queryable relationships |
eloquentAiScopes() |
null (auto-discover) |
Whitelist exposed scopes |
eloquentAiBaseQuery() |
null |
Always-applied WHERE constraint |
eloquentAiMaxRows() |
null (use global) |
Per-model row limit |
eloquentAiReadOnly() |
true |
Always true (read-only engine) |
eloquentAiQueries() |
[] |
Custom query handlers (see Custom Query Handlers) |
The trait integrates across the full pipeline: ModelDiscovery → ModelReflector → QueryBuilderEngine (applies base query + max rows) → ModelResolver (registers aliases) → MessageFormatter (uses labels in responses).
Standard pattern matching handles common queries (count, list, find, aggregate), but some questions require custom business logic — "which inspectors are online?", "what's today's schedule?", "show system overview". Custom Query Handlers let models define their own question-to-logic mappings that the chatbot invokes directly.
There are three ways to register custom queries, in order of priority:
Decorate static methods on your model with the #[EloquentAiQuery] attribute:
use Ubxty\EloquentAi\Attributes\EloquentAiQuery;
use Ubxty\EloquentAi\Traits\HasEloquentAi;
class User extends Model
{
use HasEloquentAi;
#[EloquentAiQuery(
phrases: ['inspectors online', 'who is online', 'online inspectors'],
description: 'Shows inspectors currently active in the system',
category: 'monitoring',
priority: 90,
)]
public static function getOnlineInspectors(): Collection
{
return static::query()
->whereHas('activeSessions')
->where('role', 'inspector')
->select('name', 'email', 'phone')
->get();
}
}Attribute parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
phrases |
array<string> |
[] |
Natural language phrases that trigger this handler |
description |
string |
'' |
Human-readable description shown in suggestions |
category |
?string |
null |
Category for grouping (displayed in help) |
showInSuggestions |
bool |
true |
Whether to show in help / suggestion lists |
priority |
int |
50 |
Higher = matched first (0-100) |
Define an eloquentAiQueries() static method on models using the HasEloquentAi trait:
class User extends Model
{
use HasEloquentAi;
public static function eloquentAiQueries(): array
{
return [
'online_inspectors' => [
'phrases' => ['inspectors online', 'who is online'],
'description' => 'Shows online inspectors with active sessions',
'category' => 'monitoring',
'priority' => 90,
'handler' => fn () => static::query()
->whereHas('activeSessions')
->select('name', 'email', 'phone')
->get(),
],
'inspector_locations' => [
'phrases' => ['where are inspectors', 'inspector locations'],
'description' => 'Last known GPS location of active inspectors',
'method' => 'getInspectorLocations', // calls User::getInspectorLocations()
],
];
}
}Each query entry supports:
| Key | Required | Description |
|---|---|---|
phrases |
Yes | Array of trigger phrases |
description |
No | Shown in suggestions |
handler |
No* | Closure that returns results |
method |
No* | Static method name on the model |
category |
No | Grouping category |
priority |
No | Match priority (default: 50) |
show_in_suggestions |
No | Show in help (default: true) |
*Provide either handler (closure) or method (static method name).
For queries not tied to a specific model, or to keep logic in config:
// config/eloquent-ai.php
'custom_queries' => [
'system_overview' => [
'phrases' => ['system overview', 'dashboard summary', 'system stats'],
'description' => 'High-level system statistics',
'category' => 'admin',
'priority' => 80,
'handler' => fn () => [
'total_users' => User::count(),
'active_sessions' => UserSession::where('is_active', true)->count(),
'reports_today' => Report::whereDate('created_at', today())->count(),
],
],
'pending_reports' => [
'phrases' => ['pending reports', 'reports pending review'],
'description' => 'Reports awaiting company review',
'model' => Report::class,
'handler' => fn () => Report::where('status', 'pending')
->with('inspector:id,name')
->select('id', 'title', 'inspector_id', 'created_at')
->latest()
->limit(20)
->get(),
'priority' => 85,
],
],Config-based queries support the same keys as the trait method, plus:
model— optional model class (used for field filtering/rendering)- Closures in config are not cached (they're reloaded each request)
- Boot — On first request,
CustomQueryResolverscans all models for#[EloquentAiQuery]attributes andeloquentAiQueries()methods, caches the registry, then loads config queries. - Match — The user's question is scored against all registered phrases using exact match (1.0), substring containment (0.8+), and word overlap (0.5-0.75).
- Priority — Custom queries with score ≥ 0.6 are tried before standard pattern matching. If pattern matching fails, custom queries with score ≥ 0.5 are tried as a fallback.
- Execute — The matched handler is called and results are normalized, filtered, rendered (field types, cards), and returned.
- Cache — The attribute/trait registry is cached (respects
schema.cache_ttl). Usephp artisan eloquent-ai:scan-schema --freshto clear.
| Command | Description |
|---|---|
eloquent-ai:install |
Full installation: publish configs, run migrations, scan schema & routes |
eloquent-ai:publish |
Publish specific assets: --config, --migrations, --views, --vue, --css, --all |
eloquent-ai:scan-schema |
Re-scan and cache database schema (models, columns, relationships) |
eloquent-ai:scan-routes |
Re-scan routes and generate config/eloquent-ai-routes.php |
eloquent-ai:index-advisor |
Analyze schema and suggest database indexes for performance |
eloquent-ai:test-connection |
Verify AI provider connectivity (for ai/auto modes) |
eloquent-ai:audit-prune |
Prune old audit log entries (--days=90) |
use Ubxty\EloquentAi\EloquentAiFacade as EloquentAi;
// Ask a question
$result = EloquentAi::ask('How many active users are there?');
// Returns: ['success' => true, 'data' => [...], 'conversation_id' => '...']
// Get suggestions
$suggestions = EloquentAi::suggest('show me');
// Check engine mode
$mode = EloquentAi::engineMode(); // 'local', 'ai', or 'auto'
// Scan schema
$graph = EloquentAi::scanSchema();
// Horizon-style auth gate
EloquentAi::auth(fn ($user) => $user->hasRole('ADMIN'));
// Before/after hooks
EloquentAi::beforeQuery(function ($question, $user) {
Log::info("User {$user->id} asked: {$question}");
return false; // Return false to block the query
});
EloquentAi::afterQuery(function ($response, $user) {
// Post-processing, notifications, etc.
});User Question ("How many users created this year?")
│
▼
┌──────────────────┐
│ Input Sanitize │ ← Strip SQL injection, XSS, command injection
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Pre-Normalizer │ ← "were created this year" → "where created_at this year"
│ + Tokenizer │ ← "day plans" → "dayplans" (multi-word alias)
└────────┬─────────┘
│
▼
┌───────────────────────┐
│ Engine Router │
│ (local / ai / auto) │
└────────┬──────────────┘
│
┌────┴─────┐
▼ ▼
┌───────┐ ┌───────┐
│ Local │ │ AI │ ← laravel/ai Agent (optional)
│Engine │ │Engine │
└───┬───┘ └───┬───┘
│ │
▼ ▼
┌──────────────────┐
│ Query Plan │ ← { model, operation, filters, intent, ... }
└────────┬─────────┘
│
┌────┴─────────────────┐
│ Intent Router │
├──────────────────────┤
│ greeting → welcome │
│ help → query guide │
│ export → ExportHandler│
│ query → continue ↓ │
└────────┬─────────────┘
│
▼
┌──────────────────┐
│ Security Layer │ ← Table filter, Column filter, Row-level security,
│ │ Complexity scoring, Rate limiting
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Query Builder │ ← Builds Eloquent query (never raw SQL)
│ + Virtual Cols │ ← keyword_filters, virtual_columns applied
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Execute │ ← Uses cursor for large result sets, read-only connection
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Response Filter │ ← Hide UUIDs, FKs, soft deletes
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Field Renderer │ ← coords→map, email→mailto, status→badge
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Card Renderer │ ← Table or rich card layout
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Format + Audit │ ← Suggestions, navigation links, conversation log
└──────────────────┘
The built-in NaturalLanguageEngine parses queries through a multi-stage pipeline:
- Noise word stripping — removes filler ("the", "individual", "records", "please")
- Pre-normalization — converts natural phrasing to structured triggers ("were created this year" → "where created_at this year")
- Multi-word tokenization — "day plans" → "dayplans" (pre-tokenized so regex stays simple)
- Pattern matching — 30+ regex patterns for intents: count, aggregate, list, find, group, latest, oldest, export, greeting, help
- Temporal parsing — resolves 20+ time phrases to date ranges
- Keyword filter resolution — "active users" → keyword "active" →
WHERE active = 1 - Column + virtual column resolution — fuzzy matching, aliases, computed fields
Recognized intents:
- Counting: "how many users", "total number of orders", "count all sites"
- Aggregates: "sum of totals", "average price", "minimum age", "maximum score"
- Listing: "show me users", "list all orders", "give me the products", "find all runs"
- Filtering: "users where status is active", "orders with total > 100", "active users"
- Temporal: "users created this year", "reports submitted today", "orders from last month"
- Sorting: "latest orders", "oldest users", "users ordered by name"
- Grouping: "count users by account type", "reports grouped by status"
- Finding: "find user 5", "find user with initials R"
- Relations: "users with orders", "orders with their products"
- Export: "export users as CSV", "download this as JSON"
- Greetings: "hi", "hello", "thanks", "help"
- Combined: "show me the latest 10 active users ordered by created date"
Uses laravel/ai to send the question + schema context to an AI provider. The AI returns a structured JSON query plan which is validated, security-checked, and converted to Eloquent. Never executes raw SQL.
Tries the local engine first. If it can't parse the query (returns null / clarification_required), seamlessly falls back to the AI engine. Best of both worlds — fast for common queries, smart for complex ones.
The pre-normalizer converts natural English phrasing into structured triggers the pattern matcher understands:
| Natural phrase | Normalized to | Result |
|---|---|---|
| "were created this year" | "where created_at this year" | WHERE created_at >= '2026-01-01' |
| "submitted today" | "where created_at today" | WHERE DATE(created_at) = today |
| "from last month" | "where created_at last month" | WHERE created_at BETWEEN '2026-02-01' AND '2026-02-28' |
| "are active" | "where active" | Resolved via keyword_filters config |
| "in 2025" | "where created_at in 2025" | WHERE YEAR(created_at) = 2025 |
| "modified yesterday" | "where updated_at yesterday" | WHERE DATE(updated_at) = yesterday |
Supported temporal phrases: today, yesterday, this week, last week, this month, last month, this year, last year, this quarter, last quarter, past 7 days, past 30 days, past 90 days, recent, in {YYYY}.
Multi-word model names like "day plans", "inspection images", "site inspections" are pre-tokenized to single tokens before pattern matching. This keeps the regex patterns simple and reliable:
"how many day plans" → tokenize "day plans" → "dayplans" → match → de-tokenize → DayPlan model
These all work out of the box with the local engine:
| Natural Language | What It Generates |
|---|---|
| "How many users are there?" | User::count() |
| "How many active users?" | User::where('active', 1)->count() (via keyword filter) |
| "How many reports were submitted today?" | Report::whereDate('created_at', today())->count() |
| "How many runs were created this year?" | Run::where('created_at', '>=', '2026-01-01')->count() |
| "Sum of order totals" | Order::sum('total') |
| "Average user age" | User::avg('age') |
| "Show me the latest 10 users" | User::latest()->limit(10)->get() |
| "List orders where status is pending" | Order::where('status', 'pending')->get() |
| "Find user 5" | User::find(5) |
| "Find user with initials R" | User::whereRaw("UPPER(CONCAT(LEFT(name, 1))) = ?", ['R']) |
| "Count orders grouped by status" | Order::groupBy('status')->selectRaw(...)->get() |
| "Users created last month" | User::whereBetween('created_at', [Feb 1, Feb 28])->get() |
| "Orders with total between 100 and 500" | Order::whereBetween('total', [100, 500])->get() |
| "Show users with their orders" | User::with('orders')->get() |
| "Export users as CSV" | Generates CSV download link |
| "Export this as JSON" | Exports previous query results as JSON |
| "Where is inspector John?" | Returns lat/lng with Google Maps link |
| "Show me approved reports" | Report::where('status', 'approved')->get() |
| "What sites do we have?" | Site::limit(100)->get() |
| "What companies do we have?" | Company::limit(100)->get() |
| "Oldest 5 products" | Product::oldest()->limit(5)->get() |
The package implements 7 security layers in depth:
| Layer | What | How |
|---|---|---|
| 1. Authentication | Only auth'd users | Configurable middleware + Gate |
| 2. Input Sanitization | Block injection | Strips SQL keywords, XSS, command injection from input |
| 3. Schema ACL | Control visibility | Hidden tables, redacted columns, model allow/deny lists |
| 4. AI Validation | Structured output only | AI returns JSON query plans, never freeform SQL |
| 5. Query Complexity | Block expensive queries | Complexity scoring, reject if score > limit |
| 6. Execution Constraints | Limit scope | Max rows, timeout, read-only connection, no write operations |
| 7. Audit Logging | Full trail | Every query logged with user, timestamp, plan, result, execution time |
Critical production settings:
// config/eloquent-ai.php
'database_connection' => 'readonly', // Use a read-only DB connection
'query' => [
'allow_write_operations' => false, // NEVER enable in production
'allow_full_table_scan' => false, // Require filters/aggregates
'complexity_score_limit' => 50, // Block complex queries
'max_rows' => 100, // Limit result size
],Virtual column safety: All virtual column expressions use parameterized whereRaw() bindings — never string interpolation.
Aggregate function validation: Only count, sum, avg, min, max are allowed. Column names are validated against the schema before use in DB::raw().
The package is designed to handle databases with millions of records:
- Cursor-based iteration — large result sets use
cursor()instead ofget()to avoid loading everything into memory - Schema caching — schema is scanned once and cached for the configured TTL (default: 1 hour)
- Execution timeout — gracefully handled (skipped on databases that don't support
MAX_EXECUTION_TIME) - Complexity scoring — queries with too many joins/filters are rejected before execution
- Aggregate exemption —
COUNT(*),SUM(),AVG()are exempt from "full table scan" checks since they don't load rows into memory
The built-in IndexAdvisor analyzes your schema and suggests database indexes that would speed up common query patterns:
php artisan eloquent-ai:index-advisorOutput:
╔══════════════════════════════════════════════════════════════╗
║ Eloquent AI — Index Advisor ║
╠══════════════════════════════════════════════════════════════╣
║ High Priority ║
║ • users.role — frequently filtered, no index ║
║ • orders.status — used in GROUP BY, no index ║
║ • products.active — boolean filter, high cardinality table ║
╠══════════════════════════════════════════════════════════════╣
║ Medium Priority ║
║ • orders.created_at — range queries on large table ║
║ • customers.country — frequent WHERE clause, no index ║
╚══════════════════════════════════════════════════════════════╝
The advisor is also available via the API at GET /eloquent-ai/index-advisor and shown on the dashboard.
Register custom plugins to extend response processing:
// config/eloquent-ai.php
'plugins' => [
App\Plugins\ChartPlugin::class,
App\Plugins\NotificationPlugin::class,
],Implement the PluginInterface:
use Ubxty\EloquentAi\Plugins\Contracts\PluginInterface;
class ChartPlugin implements PluginInterface
{
public function name(): string { return 'chart'; }
public function description(): string { return 'Generates chart data from grouped results'; }
public function shouldHandle(array $queryPlan, array $results): bool
{
return ($queryPlan['operation'] ?? '') === 'group_count';
}
public function handle(array $queryPlan, array $results): array
{
return [
'chart' => [
'type' => 'bar',
'labels' => array_column($results, 'group'),
'values' => array_column($results, 'count'),
],
];
}
}The package dispatches events at each stage of the pipeline:
| Event | When | Payload |
|---|---|---|
SchemaScanned |
Schema scan completed | $graph, $modelCount, $tableCount |
QueryReceived |
Question submitted | $question, $userId |
QueryPlanGenerated |
Engine produced a plan | $queryPlan |
QueryPlanRejected |
Security blocked a query | $queryPlan, $reason |
QueryExecuted |
Query ran successfully | $formattedResult, $executionTimeMs |
QueryFailed |
Query execution failed | $model, $error, $queryPlan |
// EventServiceProvider
use Ubxty\EloquentAi\Events\QueryExecuted;
Event::listen(QueryExecuted::class, function ($event) {
Log::info('Eloquent AI query', [
'execution_time_ms' => $event->executionTimeMs,
]);
});use Ubxty\EloquentAi\EloquentAiFacade as EloquentAi;
public function test_dashboard_shows_user_count(): void
{
EloquentAi::fake();
EloquentAi::fakeResponse(['total_rows' => 42]);
$result = EloquentAi::ask('How many users?');
EloquentAi::assertQueried('How many users?');
$this->assertEquals(42, $result['data']['meta']['total_rows']);
}The package ships with a comprehensive test suite (215+ tests, 489+ assertions) covering:
| Test File | Coverage |
|---|---|
PatternMatcherTest |
33+ query patterns: count, list, find, aggregate, group, latest, oldest, export, greeting |
NaturalLanguageEngineTest |
Full pipeline: normalize → match → plan → temporal → keyword filters |
FilterParserTest |
Conditions: equals, greater/less, between, contains, null, temporal |
ModelResolverTest |
Alias resolution, display labels, priorities, multi-word aliases |
ColumnResolverTest |
Column matching, fuzzy matching, virtual columns |
SuggestionEngineTest |
Overview, per-model, grouped columns, example queries |
QueryBuilderEngineTest |
All operation types, filters, sorting, eager loading |
QueryValidatorTest |
Column validation, redacted columns, operator validation |
QueryComplexityScorerTest |
Complexity scoring, threshold rejection |
ResultFormatterTest |
Record formatting, aggregate formatting |
MessageFormatterTest |
Response structure, display labels, navigation |
ConversationManagerTest |
Conversation CRUD, history, expiry |
SchemaGraphTest |
Model/table/column indexing, relationships |
TableFilterTest |
Hidden table filtering |
ColumnFilterTest |
Redacted column filtering |
RowLevelSecurityTest |
Row-level WHERE injection |
QueryAuditorTest |
Security auditing, blocked queries |
IndexAdvisorTest |
Index suggestions, priority ranking |
"You are not authorized to use Eloquent AI"
- Check your
EloquentAi::auth()callback, orconfig/eloquent-ai.php→accesssettings - Ensure role names match exactly (case-sensitive:
'ADMIN'≠'Admin')
"CSRF token mismatch"
- Ensure
access.csrf_exemptistruein config (default) - Or add the XSRF-TOKEN cookie header from your SPA
"I couldn't understand that query"
- Check
natural_language.model_aliasesfor your table - Add
keyword_filtersfor domain-specific terms - Add
column_aliasesif users reference columns by different names - The local engine handles 30+ patterns — check the Query Examples section
"AI provider not available"
- Install
laravel/ai:composer require laravel/ai - Or switch to local mode:
ELOQUENT_AI_ENGINE=local
Schema not detecting models
- Run
php artisan eloquent-ai:scan-schema - Check
schema.model_directoriesandschema.exclude_models
"Query complexity too high"
- Increase
query.complexity_score_limitin config - Or simplify the question (fewer joins, filters)
UUIDs / foreign keys showing in results
- Check
response.field_visibility.hide_uuid_fieldsistrue - Add model-specific overrides in
field_visibility.per_model
No suggestions showing
- Run
php artisan eloquent-ai:scan-schema - Check
natural_language.suggestions.enabledistrue - Add
example_queriesin themodelsconfig for custom suggestions
"MAX_EXECUTION_TIME" error
- This is gracefully handled — the package catches and skips this on MariaDB / older MySQL
Export not working
- Check
export.enabledistrue - For mapped exports, ensure the controller and route name are correct
- For inline exports, check
export.allow_inlineistrue
-
CSV/JSON export✅ Implemented -
Rich card views✅ Implemented -
Index advisor✅ Implemented -
Temporal queries✅ Implemented -
Virtual columns✅ Implemented -
Keyword filters✅ Implemented -
Database dashboard✅ Implemented -
Field renderers✅ Implemented (maps, email, phone, status badges) - Saved queries & templates
- Scheduled reports (cron-based)
- Chart/graph rendering plugin
- Excel/PDF export formats
- Team sharing & collaborative dashboards
- Slack/Discord bot integration
- Write operations (with approval workflow)
- Predictive analytics & anomaly detection
- Admin panel for query history & analytics
- Vector search integration
- GraphQL endpoint
- White-label / embeddable SDK
packages/ubxty/eloquent-ai/
├── composer.json
├── README.md
├── LICENSE
├── config/
│ ├── eloquent-ai.php # Main configuration (all options)
│ └── eloquent-ai-routes.php # Auto-generated route-model mappings
├── database/migrations/
│ ├── ..._create_eloquent_ai_audit_logs_table.php
│ └── ..._create_eloquent_ai_conversations_table.php
├── resources/
│ ├── js/
│ │ ├── EloquentAiChat.vue # Vue 3 floating chat widget
│ │ └── EloquentAiDashboard.vue # Vue 3 dashboard component
│ ├── css/
│ │ └── eloquent-ai.css # Standalone CSS for non-Vue apps
│ └── views/
│ ├── chat.blade.php # Full-page chat (Blade)
│ ├── widget.blade.php # Embeddable widget (Blade directive)
│ └── dashboard.blade.php # Full-page dashboard (Blade)
├── routes/
│ └── eloquent-ai.php # Package routes
└── src/
├── EloquentAiServiceProvider.php # Auto-discovery service provider
├── EloquentAiFacade.php # Facade with Horizon-style auth()
├── Traits/
│ └── HasEloquentAi.php # Model opt-in trait
├── Schema/ # Schema intelligence engine
│ ├── SchemaScanner.php # Orchestrates schema discovery
│ ├── SchemaGraph.php # In-memory schema representation
│ ├── SchemaCache.php # Cache layer
│ ├── ModelDiscovery.php # Finds Eloquent models
│ ├── ModelReflector.php # Extracts model metadata
│ ├── DatabaseIntrospector.php # Raw DB schema introspection
│ └── ColumnClassifier.php # Classifies column types
├── NaturalLanguage/ # Local engine (no AI needed)
│ ├── NaturalLanguageEngine.php # Main engine: normalize → match → plan
│ ├── PatternMatcher.php # 30+ regex patterns for query intents
│ ├── ModelResolver.php # Maps aliases → model classes
│ ├── ColumnResolver.php # Maps aliases → column names
│ ├── FilterParser.php # Parses conditions + temporal phrases
│ └── SuggestionEngine.php # Smart contextual suggestions
├── AI/ # AI engine (optional, via laravel/ai)
│ ├── AIManager.php # AI provider manager
│ ├── Agents/
│ │ └── DatabaseQueryAgent.php # laravel/ai structured agent
│ ├── PromptBuilder.php # Builds AI prompts with schema context
│ ├── ContextBuilder.php # Builds schema context for prompts
│ └── ResponseParser.php # Parses AI responses into query plans
├── Query/ # Query building & execution
│ ├── QueryPlan.php # Structured query plan object
│ ├── QueryValidator.php # Validates plan against schema
│ ├── QueryComplexityScorer.php # Scores query complexity
│ ├── QueryBuilderEngine.php # Builds Eloquent query from plan
│ ├── QueryExecutor.php # Executes query with safety constraints
│ ├── ResultFormatter.php # Formats raw results
│ ├── ResponseFieldFilter.php # Hides UUIDs, FKs, timestamps
│ ├── FieldRenderer.php # Rich field transforms (maps, badges)
│ └── CardRenderer.php # Card layout rendering
├── Chat/ # Chat interface layer
│ ├── ChatService.php # Main orchestrator
│ ├── ChatController.php # HTTP endpoints
│ ├── DashboardController.php # Dashboard API + page
│ ├── ConversationManager.php # Conversation state management
│ ├── StreamHandler.php # SSE streaming support
│ └── MessageFormatter.php # Response formatting + suggestions
├── Export/
│ └── ExportHandler.php # CSV/JSON export + mapped exports
├── Security/ # 7-layer security
│ ├── AccessControl.php # Gate + role authorization
│ ├── InputSanitizer.php # SQL/XSS injection prevention
│ ├── TableFilter.php # Hidden table enforcement
│ ├── ColumnFilter.php # Redacted column enforcement
│ ├── RowLevelSecurity.php # Row-level WHERE injection
│ ├── QueryAuditor.php # Audit logging
│ └── RateLimiter.php # Rate limiting
├── Performance/
│ └── IndexAdvisor.php # Database index suggestions
├── Routes/ # Route scanning & URL generation
│ ├── RouteScanner.php # Scans Laravel routes
│ ├── RouteModelMapper.php # Maps routes to models
│ ├── RouteConfigGenerator.php # Generates route config file
│ ├── UrlGenerator.php # Generates navigation URLs
│ └── Contracts/
│ └── UrlResolverInterface.php
├── Plugins/
│ ├── PluginManager.php # Plugin lifecycle manager
│ └── Contracts/
│ └── PluginInterface.php
├── Events/ # Pipeline events
│ ├── SchemaScanned.php
│ ├── QueryReceived.php
│ ├── QueryPlanGenerated.php
│ ├── QueryPlanRejected.php
│ ├── QueryExecuted.php
│ └── QueryFailed.php
├── Http/
│ ├── Middleware/
│ │ └── EloquentAiMiddleware.php
│ └── Requests/
│ └── AskQuestionRequest.php
└── Console/ # Artisan commands
├── InstallCommand.php
├── PublishCommand.php
├── ScanSchemaCommand.php
├── ScanRoutesCommand.php
├── IndexAdvisorCommand.php
├── TestConnectionCommand.php
└── AuditPruneCommand.php
Bug reports and pull requests are welcome at github.com/ubxty/eloquent-ai.
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Add tests for your change
- Run
vendor/bin/pint --dirtyto fix code style - Open a pull request
See CHANGELOG.md for a full list of changes per release.
MIT — see LICENSE.md.
Author: Ravdeep Singh — info.ubxty@gmail.com
Built with:
- Laravel / Eloquent ORM
- laravel/ai (optional — for AI engine mode)
- Vue 3 (floating chat widget)