Build a Laravel Tool Registry So AI Agents Can Discover Approved Business Actions

If you’ve spent any time over the last year building systems that connect Large Language Models to real-world software, you’ve likely hit what I call the LLM Orchestration Wall.

It usually starts innocently enough. You write a neat little controller method in Laravel, hook up OpenAI’s function calling (or an open-source agent framework like LangChain, CrewAI, or Laravel's native ecosystem integrations), and let your model query the database. Within an hour, your AI agent can look up user subscriptions, generate invoices, and send Slack alerts. It feels like magic.

Then reality sets in.

Your product manager walks over and asks: "Can the AI agent that handles billing support also issue refunds? But only if the user has an active enterprise plan and hasn't received a refund in the last 30 days."

Suddenly, your clean, declarative tool definitions are drowned in a sea of hardcoded conditional logic. Your prompts swell to 4,000 tokens just to explain who is allowed to call what, under which circumstances. Worse, you realize that your AI agent is executing database queries with the privileges of a system-level process because you forgot to scope the model's intent to the currently authenticated user's permissions.

When we talk about building autonomous agents in enterprise Laravel applications, the bottleneck isn't the model's reasoning capability. It’s discovery, authorization, and boundaries.

If an AI agent is going to act as a reliable software engineer, customer success rep, or operations manager, it cannot just "know" about every endpoint in your application. It needs a structured, secure, and dynamic Tool Registry—a single source of truth that defines what actions are possible, who can run them, how they are validated, and how they report back.

Here is how you design and build a production-grade Laravel Tool Registry that bridges the gap between unpredictable LLM function calling and rigid business rules.


📋 Table of Contents


The Real Problem: Why Function Calling is Not Enough

Most AI SDK documentation presents tool calling as a simple mapping problem: you define an array of JSON schemas, pass them to the API payload, catch the tool call response in your controller, execute a PHP function, and send the result back.

{
  "name": "cancel_subscription",
  "description": "Cancels a customer subscription.",
  "parameters": {
    "type": "object",
    "properties": {
      "subscription_id": { "type": "string" }
    },
    "required": ["subscription_id"]
  }
}

This works wonderfully in a local sandbox. In a real multi-tenant Laravel application running in production, it completely falls apart for three distinct reasons:

  1. Global Visibility vs. Local Authorization: If you pass every tool definition to every model invocation, a low-level support agent model might inadvertently discover and attempt to execute administrative tools like delete_database_cluster simply because its system prompt was weak.
  2. Implicit Context Loss: LLMs do not know about $request->user(), current team IDs, active database connections, or feature flags unless you explicitly inject them into the tool execution context. If a tool accepts a user_id, what stops the LLM from hallucinating an ID belonging to another tenant?
  3. Rigid Schema Evolution: When your underlying Eloquent models or service classes change, keeping your OpenAI JSON schemas synchronized manually with your PHP validation rules leads to silent failures and frustrating debugging sessions.

To solve this, we need to treat AI tools first-class citizens in Laravel—just like jobs, events, or route handlers.


1. The Anatomy of a Declared Tool: Beyond JSON Schema

Instead of scattering JSON schemas across configuration files or inline controller code, let's encapsulate each business action inside a dedicated Tool class. Think of a Tool as a cross between a Laravel Queue Job and an Invokable Controller, augmented with semantic metadata for LLMs.

Scenario: Your application manages cloud infrastructure. You want an AI agent to scale Kubernetes pods, but the action requires strict validation, telemetry tracking, and authorization guards.

Why it matters: An LLM doesn't understand PHP type hints natively; it understands natural language descriptions and strict JSON schemas. If your descriptions are ambiguous, the model will pass garbage parameters.

Solution: Create an abstract Tool base class and concrete implementations that define their own schemas, permissions, and execution logic using PHP attributes and strongly typed contracts.

namespace App\AI\Tools;

use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Gate;

abstract class Tool
{
    /**
     * The unique machine-readable name of the tool.
     */
    abstract public static function name(): string;

    /**
     * A clear, natural language description for the LLM.
     */
    abstract public static function description(): string;

    /**
     * The JSON Schema definition for the tool parameters.
     */
    abstract public static function schema(): array;

    /**
     * Determine if the given user is authorized to discover and run this tool.
     */
    public function authorize(?Authenticatable $user): bool
    {
        return true;
    }

    /**
     * Execute the underlying business logic.
     */
    abstract public function handle(array $arguments, ?Authenticatable $user): array|string;
}

Now let's implement a concrete tool for scaling Kubernetes deployments:

namespace App\AI\Tools\Infrastructure;

use App\AI\Tools\Tool;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;

class ScaleKubernetesDeploymentTool extends Tool
{
    public static function name(): string
    {
        return 'scale_kubernetes_deployment';
    }

    public static function description(): string
    {
        return 'Scale a Kubernetes deployment up or down within the production cluster. Use this only when explicit user confirmation has been granted.';
    }

    public static function schema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'deployment_name' => [
                    'type' => 'string',
                    'description' => 'The exact name of the K8s deployment (e.g., api-gateway, worker-node).',
                ],
                'replicas' => [
                    'type' => 'integer',
                    'description' => 'Target number of replicas. Must be between 1 and 20.',
                    'minimum' => 1,
                    'maximum' => 20,
                ],
                'reason' => [
                    'type' => 'string',
                    'description' => 'Operational justification for scaling.',
                ],
            ],
            'required' => ['deployment_name', 'replicas', 'reason'],
        ];
    }

    public function authorize(?Authenticatable $user): bool
    {
        // Leverage Laravel's native Gates/Policies
        return $user && $user->can('manage-infrastructure');
    }

    public function handle(array $arguments, ?Authenticatable $user): array
    {
        if ($arguments['replicas'] > 10 && !$user->hasRole('super-admin')) {
            throw ValidationException::withMessages([
                'replicas' => 'Scaling beyond 10 replicas requires super-admin approval.'
            ]);
        }

        // Execute internal cluster service call
        $response = Http::withToken(config('services.k8s.token'))
            ->post(config('services.k8s.url').'/scale', [
                'deployment' => $arguments['deployment_name'],
                'replicas' => $arguments['replicas'],
            ]);

        if ($response->failed()) {
            return [
                'status' => 'error',
                'message' => 'Failed to reach orchestration cluster: ' . $response->body(),
            ];
        }

        return [
            'status' => 'success',
            'deployment' => $arguments['deployment_name'],
            'current_replicas' => $arguments['replicas'],
            'timestamp' => now()->toIso8601String(),
        ];
    }
}

🧠 The important part: By encapsulating the schema, authorization check, and execution inside a single class, you eliminate the risk of a disconnect between what the LLM is told it can do and what your backend security layer actually permits.


2. Building the Core Registry Service Container

Once you have multiple tools scattered across your app/AI/Tools directory, you need a central catalog that manages their lifecycle. This should be registered as a singleton in Laravel's service container so it can inspect, cache, and resolve tools efficiently.

Scenario: Your application needs to feed 50 different tool definitions to OpenAI on every turn of a complex multi-turn conversation without manually instantiating classes every time.

Why it matters: Scanning the filesystem or querying the database on every HTTP request introduces severe performance degradation. We need a registry that auto-discovers tools at boot time or via artisan cache commands.

Solution: Build a ToolRegistry service that uses Laravel's class finder to discover tool classes, registers them, and exposes methods to export schemas formatted for various LLM providers.

namespace App\AI;

use App\AI\Tools\Tool;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Collection;
use InvalidArgumentException;

class ToolRegistry
{
    /**
     * Registered tool class mappings.
     * 
     * @var array<string, class-string<Tool>>
     */
    protected array $tools = [];

    /**
     * Register a tool class into the registry.
     */
    public function register(string $toolClass): self
    {
        if (!is_subclass_of($toolClass, Tool::class)) {
            throw new InvalidArgumentException("Class [{$toolClass}] must extend [" . Tool::class . "].");
        }

        $this->tools[$toolClass::name()] = $toolClass;

        return $this;
    }

    /**
     * Retrieve all tools filtered by user authorization.
     */
    public function getAvailableTools(?Authenticatable $user = null): Collection
    {
        return collect($this->tools)
            ->map(fn (string $class) => new $class())
            ->filter(fn (Tool $tool) => $tool->authorize($user));
    }

    /**
     * Export schemas formatted for OpenAI / Anthropic function calling payloads.
     */
    public function exportSchemas(?Authenticatable $user = null): array
    {
        return $this->getAvailableTools($user)
            ->map(fn (Tool $tool) => [
                'type' => 'function',
                'function' => [
                    'name' => $tool::name(),
                    'description' => $tool::description(),
                    'parameters' => $tool::schema(),
                ],
            ])
            ->values()
            ->all();
    }

    /**
     * Execute a specific tool by name with validated arguments.
     */
    public function execute(string $name, array $arguments, ?Authenticatable $user = null): mixed
    {
        if (!isset($this->tools[$name])) {
            throw new InvalidArgumentException("Tool [{$name}] is not registered.");
        }

        /** @var Tool $tool */
        $tool = new $this->tools[$name]();

        if (!$tool->authorize($user)) {
            throw new \Illuminate\Auth\Access\AuthorizationException("Unauthorized execution of tool [{$name}].");
        }

        return $tool->handle($arguments, $user);
    }
}

Register this service in your AppServiceProvider or bind it directly:

namespace App\Providers;

use App\AI\ToolRegistry;
use Illuminate\Support\ServiceProvider;

class AIServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(ToolRegistry::class, function ($app) {
            $registry = new ToolRegistry();

            // Auto-discover tools in App\AI\Tools
            $tools = [
                \App\AI\Tools\Infrastructure\ScaleKubernetesDeploymentTool::class,
                \App\AI\Tools\Billing\IssueRefundTool::class,
                \App\AI\Tools\Support\FetchUserTicketsTool::class,
            ];

            foreach ($tools as $tool) {
                $registry->register($tool);
            }

            return $registry;
        });
    }
}

3. Context-Aware Tool Filtering for Multi-Tenant Permissions

In a SaaS application serving hundreds of tenants, an agent interacting with tenant A should never have access to tools or data belonging to tenant B. Worse yet, different subscription tiers within the same tenant might unlock advanced AI capabilities.

Scenario: A standard-tier user chats with your support bot. The bot should only see read-only CRM lookup tools. When an enterprise user logs in, the registry must dynamically inject administrative modification tools into the payload sent to the LLM.

Why it matters: If you rely solely on post-execution validation, the LLM might still attempt to call restricted tools, leading to confusing conversation loops where the model says "I tried to run X, but was denied." By filtering tools before sending schemas to the LLM API, the model never even knows the restricted tools exist.

Solution: Pass the authenticated user or active tenant context directly into the ToolRegistry::exportSchemas() method, leveraging Laravel's policy checks inside the authorize() hook of each tool.

namespace App\Http\Controllers;

use App\AI\ToolRegistry;
use Illuminate\Http\Request;
use OpenAI\Laravel\Facades\OpenAI;

class AIAgentController extends Controller
{
    public function chat(Request $request, ToolRegistry $registry)
    {
        $user = $request->user();

        // 1. Get only the tools this specific user is allowed to run
        $tools = $registry->exportSchemas($user);

        // 2. Dispatch to LLM client with scoped tool definitions
        $response = OpenAI::chat()->create([
            'model' => 'gpt-4o',
            'messages' => [
                ['role' => 'user', 'content' => $request->input('message')],
            ],
            'tools' => $tools,
            'tool_choice' => 'auto',
        ]);

        // Handle tool calls...
    }
}

⚠️ Gotcha: Be careful with caching schemas globally if your application uses multi-tenancy with dynamic role switching. If you cache ToolRegistry::exportSchemas() in Redis without partitioning by user ID or role hash, a standard user might momentarily receive enterprise tool definitions.


4. Runtime Parameter Validation with Laravel Form Requests

Even with robust JSON schemas, LLMs are notoriously creative. They might pass a string where an integer is expected, provide an invalid email format, or omit optional fields in ways that break your backend service layers.

Scenario: An LLM decides to pass replicas: "fifteen" instead of an integer 15 to our Kubernetes scaling tool because it misinterpreted conversational context.

Why it matters: Type coercion in PHP can be unpredictable. Allowing raw LLM arguments to flow directly into Eloquent queries or external HTTP clients invites runtime errors or SQL injections.

Solution: Couple your tools with Laravel's powerful validation engine before execution occurs.

namespace App\AI\Tools\Billing;

use App\AI\Tools\Tool;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;

class IssueRefundTool extends Tool
{
    public static function name(): string
    {
        return 'issue_refund';
    }

    public static function description(): string
    {
        return 'Issue a financial refund to a customer transaction.';
    }

    public static function schema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'transaction_id' => ['type' => 'string'],
                'amount' => ['type' => 'number', 'description' => 'Amount in USD'],
                'reason_code' => [
                    'type' => 'string',
                    'enum' => ['duplicate', 'fraudulent', 'requested_by_customer'],
                ],
            ],
            'required' => ['transaction_id', 'amount', 'reason_code'],
        ];
    }

    public function handle(array $arguments, ?Authenticatable $user): array
    {
        // Validate arguments against rigid business rules
        $validator = Validator::make($arguments, [
            'transaction_id' => ['required', 'string', 'exists:transactions,id'],
            'amount' => ['required', 'numeric', 'min:0.01', 'max:500.00'],
            'reason_code' => ['required', 'string', 'in:duplicate,fraudulent,requested_by_customer'],
        ]);

        if ($validator->fails()) {
            // Return structured validation errors back to the LLM so it can self-correct!
            return [
                'status' => 'validation_error',
                'errors' => $validator->errors()->toArray(),
            ];
        }

        $validated = $validator->validated();

        // Perform refund logic...
        return [
            'status' => 'success',
            'refund_id' => 'ref_' . \Illuminate\Support\Str::random(10),
            'amount' => $validated['amount'],
        ];
    }
}

Notice the design choice here: instead of throwing an uncaught ValidationException that halts the HTTP request, we catch validation errors and return them as a tool response to the LLM. This enables the model to read the error message ("The amount must be between 0.01 and 500.00"), adjust its behavior, and apologize or re-prompt the user correctly in the chat interface.


5. Asynchronous Tool Execution and Queue Offloading

Some tools—like generating a complex PDF report, querying a sluggish legacy SOAP service, or executing database migrations—take longer than a standard HTTP request lifecycle allows. If an LLM triggers a tool that takes 12 seconds to complete, your web worker hangs, risking gateway timeouts.

Scenario: An AI agent triggers a tool called generate_annual_audit_report. The report generation script queries 400,000 rows and compiles a PDF.

Why it matters: OpenAI and Anthropic API connections have strict timeout thresholds while waiting for tool responses. If your PHP worker takes too long to execute a tool, the HTTP connection drops, leaving the conversation in an inconsistent state.

Solution: Detect long-running or resource-intensive tools, dispatch them to Laravel's queue, and return a polling handle or immediate acknowledgement back to the LLM loop.

namespace App\AI\Tools\Reports;

use App\AI\Tools\Tool;
use App\Jobs\GenerateAuditReportJob;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Str;

class GenerateAuditReportTool extends Tool
{
    public static function name(): string
    {
        return 'generate_audit_report';
    }

    public static function description(): string
    {
        return 'Asynchronously generate a comprehensive annual audit report PDF for a given company ID.';
    }

    public static function schema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'company_id' => ['type' => 'string'],
                'fiscal_year' => ['type' => 'integer'],
            ],
            'required' => ['company_id', 'fiscal_year'],
        ];
    }

    public function handle(array $arguments, ?Authenticatable $user): array
    {
        $jobId = (string) Str::uuid();

        // Dispatch heavy lifting to the queue worker
        GenerateAuditReportJob::dispatch(
            $jobId,
            $arguments['company_id'],
            $arguments['fiscal_year'],
            $user?->getAuthIdentifier()
        )->onQueue('ai-compute');

        return [
            'status' => 'queued',
            'job_id' => $jobId,
            'message' => 'Report generation has started in the background. Inform the user that the report will be emailed to them once complete.',
        ];
    }
}

6. Audit Logging, Tracing, and LLM Hallucination Interception

When things go wrong in a traditional Laravel app, you check Sentry or storage/logs/laravel.log. When things go wrong with an AI agent, debugging requires inspecting the exact sequence of thoughts, tool calls, and returned payloads.

Scenario: An agent is trapped in a loop, repeatedly calling fetch_user_tickets with invalid IDs because its system prompt instruction was misconfigured.

Why it matters: Without transparent telemetry around tool execution, you are flying blind. You need to log every tool invocation, arguments passed, execution duration, and resulting payload against the authenticated user ID and session token.

Solution: Use a middleware pattern or event listener around your ToolRegistry::execute() method to capture telemetry and intercept anomalies.

namespace App\AI;

use App\Models\AIToolAuditLog;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Log;
use Throwable;

class InstrumentedToolRegistry extends ToolRegistry
{
    public function execute(string $name, array $arguments, ?Authenticatable $user = null): mixed
    {
        $startTime = microtime(true);
        $status = 'success';
        $result = null;

        try {
            $result = parent::execute($name, $arguments, $user);
            
            // Check if tool returned an internal validation/error array
            if (is_array($result) && isset($result['status']) && $result['status'] === 'validation_error') {
                $status = 'validation_error';
            }

            return $result;
        } catch (Throwable $e) {
            $status = 'exception';
            $result = ['error' => $e->getMessage()];
            throw $e;
        } finally {
            $duration = round((microtime(true) - $startTime) * 1000, 2);

            // Persist audit trail asynchronously or directly
            AIToolAuditLog::create([
                'user_id' => $user?->getAuthIdentifier(),
                'tool_name' => $name,
                'arguments' => $arguments,
                'response' => $result,
                'status' => $status,
                'duration_ms' => $duration,
                'ip_address' => request()?->ip(),
            ]);

            Log::info("AI Tool Executed: [{$name}] took {$duration}ms with status [{$status}]");
        }
    }
}

7. Dynamic Tool Discovery via Database-Backed Registries

As your application matures, hardcoding your tool list in a service provider becomes restrictive. What if tenant administrators want to enable or disable specific custom webhook tools on a per-tenant basis?

Scenario: Enterprise Tenant A wants to expose their custom internal ERP lookup tool to the AI agent, while Tenant B only wants standard CRM tools.

Why it matters: Static class-based registries require code deployment every time a new tool is added. A hybrid registry allows you to combine compiled PHP tools with dynamic, user-defined plugins stored in the database.

Solution: Extend your ToolRegistry to load tools from both filesystem-backed classes and database records representing dynamic actions.

namespace App\AI\Repositories;

use App\Models\TenantTool;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Collection;

class DatabaseToolRepository
{
    public function getToolsForUser(?Authenticatable $user): Collection
    {
        if (!$user || !property_exists($user, 'tenant_id')) {
            return collect();
        }

        // Fetch custom tenant tools stored as JSON schemas and webhook URLs
        return TenantTool::where('tenant_id', $user->tenant_id)
            ->where('is_active', true)
            ->get()
            ->map(function (TenantTool $tenantTool) {
                return [
                    'type' => 'function',
                    'function' => [
                        'name' => $tenantTool->slug,
                        'description' => $tenantTool->description,
                        'parameters' => $tenantTool->json_schema,
                    ],
                    'is_dynamic' => true,
                    'webhook_url' => $tenantTool->webhook_url,
                ];
            });
    }
}

By merging filesystem tools with database records inside your controller before sending them to the LLM, you achieve ultimate extensibility without sacrificing core application security.


Production Architecture Decisions and Trade-offs

When designing an AI tool registry in Laravel, there are several architectural decisions you must weigh carefully before pushing code to production.

Dimension Class-Based Static Registry Database-Driven Dynamic Registry Hybrid Registry (Recommended)
Security High (Compiled PHP, strict static analysis) Medium (Requires rigorous JSON schema sanitization) High (Core tools static, tenant plugins sandboxed)
Flexibility Low (Requires code deployment to add tools) High (Admin UI can provision instantly) Balanced
Performance Extremely Fast (Cached in memory) Moderate (Requires database lookup per request) Fast (Cached via Redis tags)
Maintenance Clean IDE auto-completion and refactoring Requires custom validation UI and testing dashboards Requires disciplined schema versioning

Final Architectural Observation

Building a tool registry is ultimately about shifting your mindset from treating AI as a chatbot to treating AI as an authenticated client.

When you stop letting LLMs talk directly to your database and start forcing them through a strictly validated, context-aware, authorized Tool Registry, AI stops feeling like an unpredictable party trick and starts feeling like legitimate enterprise software infrastructure.

Post a Comment

Previous Post Next Post