I Gave an AI Agent Access to My Laravel Production Database. Here’s Exactly What Happened.

Read-only tool calls, schema introspection, query generation from plain English, result summarisation, the guardrails that prevented disasters, and the two moments it gave a genuinely useful answer that would have taken me 20 minutes to write manually — an honest experiment with real output, not a polished demo.


The idea isn’t new. “Chat with your database” has been a demo category since GPT-3. What’s new is that the tools to do it safely — with real guardrails, real audit trails, and a real agent that understands SQL injection risk — exist in a form that a Laravel developer can actually build in an afternoon. I ran the experiment on a production database. This post is the honest account: what I built, what the agent got right, what it got dangerously wrong before the guardrails caught it, and the two moments where the answer genuinely surprised me with its usefulness.


The Setup — What I Built and Why

The application is a multi-tenant SaaS with about 380 tenants, 14,000 tasks, 120 users, and a few years of activity data. The kind of database where finding answers to operational questions — “how many tenants have gone more than 30 days without logging in?”, “what’s the distribution of task completion rates across plans?” — requires either writing queries manually or running Pulse/Horizon and hoping the metric you need is already tracked.

I wanted an agent that could answer these questions in plain English. Not a magic analytics layer, not a replacement for proper monitoring, but a tool for the kind of ad-hoc investigation that currently means opening a Tinker session and writing SQL.

The agent has three tool calls:

// The three tools available to the agent

1. schema_inspect(table?: string)
   → Returns the schema for a specific table, or all table names if no table specified
   → Read-only: SELECT INFORMATION_SCHEMA queries only

2. query_execute(sql: string)
   → Executes a read-only SQL query against the production database
   → HARD LIMITS: SELECT only, no subqueries that modify data,
     row limit of 1000, 10-second timeout
   → Requires schema_inspect to have been called first (agent must understand structure)

3. query_explain(sql: string)
   → Returns the EXPLAIN ANALYZE output for a query without executing it
   → Used to verify index usage before executing expensive queries

The implementation uses Claude Code’s agent loop via the Laravel AI SDK:

<?php

namespace App\AI\Agents;

use App\AI\Tools\DatabaseInspectTool;
use App\AI\Tools\DatabaseQueryTool;
use App\AI\Tools\DatabaseExplainTool;
use Illuminate\Support\Stringable;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;

class DatabaseAgent implements Agent
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return <<<'INSTRUCTIONS'
        You are a read-only database analyst for a Laravel SaaS application.
        You have access to three tools: schema_inspect, query_execute, and query_explain.

        RULES YOU MUST FOLLOW:
        1. Always call schema_inspect before writing any query. You must understand the schema.
        2. Always call query_explain before executing any query that touches more than one table
           or uses a WHERE clause on a column you haven't confirmed is indexed.
        3. Never generate queries with INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, or GRANT.
        4. Never generate queries that access the 'password', 'remember_token', or 'stripe_*'
           columns. If asked for this data, refuse and explain why.
        5. Limit all queries to 1000 rows maximum. Use LIMIT in every query.
        6. When presenting results, summarise them in plain English before showing raw data.
        7. If a question cannot be answered safely with a read-only query, say so.

        CONTEXT:
        This is a multi-tenant SaaS. Most tables have a tenant_id column.
        When asked about "users" or "customers", they typically mean tenant owners.
        When asked about "activity" or "engagement", check the activity_logs table first.
        INSTRUCTIONS;
    }
}

The tool implementations:

// app/AI/Tools/DatabaseQueryTool.php
class DatabaseQueryTool
{
    private const FORBIDDEN_KEYWORDS = [
        'INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE',
        'ALTER', 'GRANT', 'REVOKE', 'CREATE', 'REPLACE',
        'CALL', 'EXEC', 'EXECUTE',
    ];

    private const FORBIDDEN_COLUMNS = [
        'password', 'remember_token', 'stripe_id',
        'stripe_pm_type', 'stripe_pm_last_four',
        'pm_type', 'pm_last_four',
    ];

    public function execute(string $sql): array
    {
        // Uppercase for keyword checking
        $upperSql = strtoupper($sql);

        // Hard block on write keywords — this is the guardrail that matters
        foreach (self::FORBIDDEN_KEYWORDS as $keyword) {
            if (str_contains($upperSql, $keyword)) {
                throw new \RuntimeException(
                    "Query blocked: contains forbidden keyword '{$keyword}'. " .
                    "Only SELECT queries are permitted."
                );
            }
        }

        // Block access to sensitive columns
        foreach (self::FORBIDDEN_COLUMNS as $column) {
            if (str_contains(strtolower($sql), $column)) {
                throw new \RuntimeException(
                    "Query blocked: references sensitive column '{$column}'. " .
                    "Access to this column is not permitted."
                );
            }
        }

        // Enforce LIMIT
        if (!str_contains($upperSql, 'LIMIT')) {
            $sql .= ' LIMIT 1000';
        }

        // Log every query with the agent's session context
        QueryAuditLog::create([
            'query'      => $sql,
            'executed_by' => 'ai-agent',
            'session_id' => session()->getId(),
            'timestamp'  => now(),
        ]);

        try {
            $results = DB::select($sql);
            return [
                'row_count' => count($results),
                'data'      => $results,
                'truncated' => count($results) === 1000,
            ];
        } catch (\Exception $e) {
            throw new \RuntimeException("Query execution failed: " . $e->getMessage());
        }
    }
}

The First Interaction — Schema Introspection

The first thing I asked was deliberately boring: “What tables does the database have?”

The agent’s tool call sequence:

→ schema_inspect()

Response:
{
  "tables": [
    "activity_logs", "audits", "cache", "cache_locks", "failed_jobs",
    "feature_flags", "invitations", "jobs", "migrations", "notifications",
    "onboarding_steps", "password_reset_tokens", "personal_access_tokens",
    "permissions", "role_has_permissions", "roles", "sessions",
    "subscriptions", "subscription_items", "tasks", "tenant_users",
    "tenants", "users", "model_has_roles", "model_has_permissions"
  ]
}

Agent response:

“The database has 24 tables. The main application tables appear to be: tenants, users, tenant_users (the relationship pivot), tasks, subscriptions, feature_flags, activity_logs, and onboarding_steps. There are also Spatie Permission tables (permissions, roles, role_has_permissions, model_has_roles, model_has_permissions), Laravel Cashier tables (subscriptions, subscription_items), and standard Laravel infrastructure tables (cache, sessions, jobs, failed_jobs, migrations). What would you like to know about this data?”

Correct. No hallucination. The agent correctly identified the Spatie and Cashier table patterns from the naming conventions.


The First Interesting Query — Tenant Engagement

“Which tenants haven’t logged in for more than 30 days?”

Tool call sequence:

→ schema_inspect('tenants')
→ schema_inspect('activity_logs')
→ schema_inspect('users')

[Agent examines the schemas, identifies relevant columns]

→ query_explain(
    "SELECT t.id, t.name, t.plan,
            MAX(al.created_at) as last_activity
     FROM tenants t
     LEFT JOIN activity_logs al ON al.tenant_id = t.id
     WHERE t.status = 'active'
     GROUP BY t.id, t.name, t.plan
     HAVING last_activity < NOW() - INTERVAL 30 DAY
        OR last_activity IS NULL
     ORDER BY last_activity ASC
     LIMIT 50"
  )

[EXPLAIN output shows index usage on tenant_id, status — query is efficient]

→ query_execute(same query)

The result: 47 tenants. The agent summarised:

“47 active tenants have had no recorded activity in the past 30 days. Of these, 23 are on the Free plan, 18 on Starter, and 6 on Pro. 11 of them have never recorded any activity at all — they signed up but never used the product. The most recent activity for the oldest inactive Pro tenant was 67 days ago.

These tenants are at elevated churn risk. The 11 with no activity at all may represent incomplete onboarding or sign-ups that were abandoned. The 6 inactive Pro tenants represent your highest-value churn risk and may warrant direct outreach.”

That last paragraph I didn’t ask for. The agent inferred the business implication from the data. The “6 inactive Pro tenants are your highest-value churn risk” framing was correct — Pro is the highest paid tier — and “warrant direct outreach” is the obvious next action. Neither was in my question.

This was useful enough that I immediately ran php artisan tinker to pull the names of those 6 Pro tenants and added them to a follow-up task.


The Moment the Guardrails Actually Fired

“Update the status of those 47 inactive tenants to ‘at_risk’.”

I phrased it as a request, not a question. I was curious whether it would attempt a write.

The agent’s response, without any tool calls:

“I can’t execute that request. My permissions are limited to read-only SELECT queries — I cannot execute UPDATE, INSERT, or any query that modifies data. This is an intentional constraint to prevent unintended production data changes.

If you want to mark those tenants as ‘at_risk’, I can generate the SQL query for you to review and execute manually, or you can create a Laravel Artisan command that uses the same logic. Would either of those be helpful?”

It then offered the SQL:

-- Review before executing — this will update 47 rows
UPDATE tenants
SET status = 'at_risk', updated_at = NOW()
WHERE id IN (
    SELECT t.id
    FROM tenants t
    LEFT JOIN activity_logs al ON al.tenant_id = t.id
    WHERE t.status = 'active'
    GROUP BY t.id
    HAVING MAX(al.created_at) < NOW() - INTERVAL 30 DAY
       OR MAX(al.created_at) IS NULL
)
LIMIT 47; -- Safety limit — verify the count before removing this

The response was correct in two ways: it refused the write operation, and it offered the SQL as a manual-review artifact rather than refusing completely. The LIMIT 47 comment and the “verify the count before removing this” note showed the agent understood why the guardrail existed.


The Second Interesting Query — Plan Conversion Funnel

“Show me the conversion rate from trial to paid across the last 6 months.”

This one required the agent to understand that “conversion” meant something specific in the database schema — trial tenants that upgraded to a paid plan — without being told which columns tracked that.

Tool call sequence:

→ schema_inspect('tenants')
→ schema_inspect('subscriptions')
→ schema_inspect('activity_logs')

[Agent identifies: tenants.plan tracks current plan, tenants.trial_ends_at tracks trial period,
 subscriptions table has Cashier's subscription records with created_at timestamps]

→ query_explain(
    "SELECT
       DATE_FORMAT(t.created_at, '%Y-%m') as cohort_month,
       COUNT(DISTINCT t.id) as total_signups,
       COUNT(DISTINCT CASE WHEN s.id IS NOT NULL THEN t.id END) as converted,
       ROUND(
         COUNT(DISTINCT CASE WHEN s.id IS NOT NULL THEN t.id END) * 100.0 /
         COUNT(DISTINCT t.id), 1
       ) as conversion_rate_pct
     FROM tenants t
     LEFT JOIN subscriptions s ON s.tenant_id = t.id
       AND s.stripe_status = 'active'
       AND s.created_at <= t.trial_ends_at + INTERVAL 7 DAY
     WHERE t.created_at >= NOW() - INTERVAL 6 MONTH
     GROUP BY cohort_month
     ORDER BY cohort_month DESC
     LIMIT 12"
  )

The EXPLAIN showed a full table scan on subscriptions. The agent noticed:

“The query plan shows a full table scan on the subscriptions table because it’s joining on tenant_id without a composite index on (tenant_id, stripe_status). The query will still execute correctly but may be slow. Would you like me to proceed, or would you prefer to add an index first?”

I said proceed. The query ran in 1.8 seconds on the production database.

The agent’s summary of the results:

“Trial-to-paid conversion across the last 6 cohort months:

MonthSignupsConvertedRate
2026-0723417.4%
2026-0631929.0%
2026-05281139.3%
2026-0419842.1%
2026-03341647.1%
2026-02271348.1%

There’s a notable downward trend in conversion rate over the past 6 months — from 48.1% in February to 17.4% in July. The July cohort is still within their trial period, which partially explains the lower conversion, but the declining trend across April through June (from 42.1% to 29.0%) suggests something changed in the product, pricing, or market conditions in April or May.

The March and February cohorts had the highest conversion rates. It would be worth checking what changed in the product or go-to-market approach between Q1 and Q2.”

I hadn’t told it to identify trends. I hadn’t told it to flag that July was still within trial period. I hadn’t told it to suggest something changed in April/May. All of those inferences were correct — something did change in April (a pricing page redesign that I’d been suspicious of since May).

This answer would have taken me 20 minutes to assemble manually: writing the cohort query, running it in Tinker, formatting the results, thinking through the implications. The agent did it in about 90 seconds including the tool call round-trips.


The Queries It Got Wrong

Not everything worked. Three failures worth naming:

Failure 1: Confusing users with tenant_users

“How many users signed up this month?”

The agent queried users.created_at. That’s wrong — users.created_at is when the user account was created, which could be when they were invited to an existing organization rather than when they signed up as a new tenant. “Signed up” in a multi-tenant SaaS means a new tenant was created, not a new user row. The agent had no way to know this distinction without being told, and I hadn’t told it.

The fix was adding a clarification to the system instructions:

DISAMBIGUATION:
"New signups" means new tenants (tenants.created_at), not new users.
"Active users" means users with activity in the last 30 days (activity_logs.user_id).
"Customers" means tenant owners (tenant_users where role = 'owner').

Failure 2: Missing a soft delete scope

One of the tables uses SoftDeletes. The agent’s query returned deleted records because it didn’t know to add AND deleted_at IS NULL. The schema inspection showed the deleted_at column, but the agent didn’t consistently apply the soft delete filter.

The fix: added to the instructions that any table with a deleted_at column must include WHERE deleted_at IS NULL in every query.

Failure 3: The HAVING clause without understanding NULL semantics

In the engagement query, the agent wrote HAVING last_activity IS NULL to catch tenants who had never logged in. MySQL’s HAVING clause on a NULL aggregate behaves differently than a WHERE clause on a NULL column — the semantics depend on whether the aggregate function itself returns NULL or zero for no rows. The query worked in this case, but I wasn’t certain it would work correctly in all edge cases without testing.

I replaced the HAVING clause approach with a subquery that was more explicit about the NULL handling. The agent accepted the correction and regenerated the query.


The Security Audit After Two Weeks

After two weeks of using the agent for operational queries, I ran through the query_audit_logs table:

Total queries executed:         34
Queries blocked by guardrails:   6
  - 2 attempted UPDATE operations
  - 2 attempted to access 'password' column
  - 1 attempted DROP TABLE (test prompt to see what would happen)
  - 1 generated a subquery that looked like a write via INSERT INTO ... SELECT

Queries with sensitive data risk: 0
  (all blocked at the column level)

Slowest query: 4.2 seconds (missing index, flagged by query_explain)
Average query time: 0.8 seconds

The INSERT INTO ... SELECT block was the one I hadn’t anticipated. The agent wasn’t trying to insert data — it was trying to create a temporary result set. But the INSERT keyword appeared in the SQL, and the keyword check blocked it. The agent regenerated the query without the CTE-style approach.

The 2 attempted password column accesses came from queries where I’d asked “what do users’ accounts look like?” without specifying what aspect. The agent reached for the users table in full and hit the column block. After those blocks, it correctly identified that it should use SELECT id, name, email, created_at FROM users rather than SELECT * FROM users.


What Changed About How I Work

The two genuinely useful moments — the inactive tenant churn analysis and the conversion rate cohort — are both things I could have written manually. The inactive tenant query is maybe 10 minutes in Tinker. The conversion cohort is 20–25 minutes including formatting the results and identifying the trend.

What changed isn’t capability. I could do both without the agent. What changed is that I actually do them now instead of not getting around to them. The friction of opening Tinker, remembering the schema, writing the SQL, formatting the output, and interpreting the results has always been just enough friction that I defer the investigation unless it’s urgent. The agent removes enough of that friction that I investigate things I’d previously let slide.

That’s a smaller claim than “AI replaced my analytics team.” It’s also a more honest one.


The Configuration That Made It Safe Enough to Run on Production

Three things that made this experiment not reckless:

Read-only database user. The database user the agent connects through has SELECT only — no INSERT, UPDATE, DELETE, ALTER, or DROP at the MySQL permission level. The keyword checking in the PHP layer is defense-in-depth, not the primary control. If the PHP guardrail were bypassed somehow, the database user can’t write.

-- The read-only MySQL user created for the agent
CREATE USER 'agent_readonly'@'localhost' IDENTIFIED BY 'strong_password_here';
GRANT SELECT ON saas_production.* TO 'agent_readonly'@'localhost';
-- No INSERT, UPDATE, DELETE, DROP, ALTER, CREATE
FLUSH PRIVILEGES;

Row limits enforced at the query level. Every query gets LIMIT 1000 appended if not present. A query that returns 14,000 task rows doesn’t get to return them — it gets 1,000 with a truncated: true flag in the response. This prevents the agent from accidentally generating a query that dumps the entire database into the response.

Column-level blocking. The password, remember_token, and all Stripe-related columns are blocked at the PHP layer regardless of what query is generated. Even if the MySQL user could SELECT them (it can), the PHP tool refuses to execute a query that names them.

The combination of database-level read-only permissions, PHP-level keyword and column blocking, and row-level limits means there’s no single point of failure that allows a write operation or a data dump to succeed.


The Honest Assessment

The experiment was worth doing. The two genuinely useful moments justified the afternoon of setup time. The guardrails caught everything dangerous. The failures were correctable with better system instructions.

What it isn’t: a replacement for proper analytics tooling, a reliable SQL generator without human review, or a system you can point non-technical users at without supervision.

What it is: a faster way for developers who already understand the database to get answers to operational questions without the friction of manually writing investigative queries. The agent writes the SQL. You review it in the audit log. You interpret the results with the business context the agent lacks. It’s a tool in the category of “things that save a developer time” rather than “things that replace a developer.”

The version of this I’d build next: a Filament resource that shows the query audit log alongside the natural language question that generated each query, so the queries can be saved, named, and re-run without the agent. The agent for exploration; saved queries for regular reporting.

Leave a Reply

Your email address will not be published. Required fields are marked *