← Back to Journal

Laravel

MCP in Laravel Applications: Adding a Natural Language Layer to Your Existing System

Petro Lashyn Apr 25, 2026 11 min

How to add MCP to a Laravel app as a controlled natural-language interface over existing services, with permissions, auditability, and production-safe tool boundaries.

Most Laravel applications follow the same structural pattern. Routes hit controllers. Controllers call actions or services. Services talk to the database through Eloquent. Jobs handle async work. Commands handle CLI tasks. The whole thing is held together with events, observers, and a queue.

It works. It scales. After years of building with this stack, I still reach for it by default, because it's honest about what it is and the abstractions don't leak.

But there is a gap that every system I've worked on eventually hits.

The UI you build covers predictable interactions. The forms, the tables, the filters, they are designed for the 80% of use cases you anticipated. The other 20% either gets queued for the next sprint, gets solved with an Excel export, or just doesn't get solved. That gap has always been there. We've called it "scope" and moved on.

MCP (Model Context Protocol) is a way to close that gap without rewriting anything.

What MCP actually is (architecturally)

MCP is a protocol. It defines how a language model can discover and call tools exposed by a server. The model knows what tools exist, what parameters they accept, and can reason about which ones to call in what sequence to satisfy a user's request.

In a Laravel context, your MCP server is a class (or a set of classes) that registers tools. Each tool is essentially a callable with a description, an input schema, and a handler. The handler can do anything your application code already does: run a query, call a service, dispatch a job, trigger a notification, generate a file.

The language model doesn't touch your database directly. It calls the tool. The tool runs your existing application code. You stay in control of what's exposed and what isn't.

That's the thing worth understanding: you're not handing the keys to the LLM. You're defining a surface (a set of capabilities), and the model works within that surface.

The application layer stack, extended

Before MCP, a Laravel application typically had three interaction layers:

HTTP layer     -> Controllers, Livewire, Inertia - user-facing UI
CLI layer      -> Artisan commands - developer/ops tooling
Queue layer    -> Jobs, workers - background processing

With MCP, you add a fourth:

MCP layer      -> Tool definitions wired to existing services - LLM-addressable interface

The existing layers don't change. Your controllers still handle form submissions. Your commands still run maintenance jobs. The MCP layer sits alongside them, exposing selected capabilities to a language model that can be queried in natural language.

A concrete implementation pattern

Suppose you have an ERP system built in Laravel. A CFO wants a custom report: revenue for a specific date range, filtered to a subset of counterparties, broken down by sales manager, with trend data.

No such view exists in the UI. Building it through normal channels takes a sprint.

Instead, you define three MCP tools.

Tool 1: Revenue query

class RevenueQueryTool extends McpTool
{
    public string $name = 'query_revenue';
    public string $description = 'Query revenue data with filters. Returns structured data.';

    public function inputSchema(): array
    {
        return [
            'date_from' => ['type' => 'string', 'format' => 'date', 'required' => true],
            'date_to' => ['type' => 'string', 'format' => 'date', 'required' => true],
            'counterparty_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
            'group_by' => ['type' => 'string', 'enum' => ['manager', 'counterparty', 'month']],
        ];
    }

    public function handle(array $input): array
    {
        return app(RevenueReportService::class)->query(
            dateFrom: $input['date_from'],
            dateTo: $input['date_to'],
            counterpartyIds: $input['counterparty_ids'] ?? [],
            groupBy: $input['group_by'] ?? 'manager',
        );
    }
}

RevenueReportService already exists. You wrote it when you built the standard reports. You're not writing new business logic; you're making existing logic callable through MCP.

Tool 2: Chart generation

class ChartGeneratorTool extends McpTool
{
    public string $name = 'generate_chart';
    public string $description = 'Generate a chart from structured data. Returns chart HTML.';

    public function handle(array $input): array
    {
        $html = app(ChartService::class)->render(
            type: $input['type'],
            data: $input['data'],
            options: $input['options'] ?? [],
        );

        return ['html' => $html];
    }
}

Tool 3: PDF export

class PdfExportTool extends McpTool
{
    public string $name = 'export_pdf';
    public string $description = 'Render HTML content to PDF. Returns a download URL.';

    public function handle(array $input): array
    {
        $path = app(PdfService::class)->fromHtml($input['html'], $input['filename']);

        return ['url' => Storage::url($path)];
    }
}

When the CFO sends the request through the AI assistant, the language model reads the available tools, determines the correct call sequence (query_revenue -> generate_chart -> export_pdf), and returns a PDF. If you've also registered an email dispatch tool, the CFO can ask for it to be sent directly.

No new controller. No new view. No new migration. The system gained a capability through composition of what already existed.

Registering tools and handling MCP requests

MCP server registration in Laravel is straightforward. You define a server class, register your tools, and expose an endpoint that speaks the MCP protocol.

class ErpMcpServer
{
    public function tools(): array
    {
        return [
            new RevenueQueryTool(),
            new ChartGeneratorTool(),
            new PdfExportTool(),
            new EmailDispatchTool(),
        ];
    }
}

The endpoint handles the JSON-RPC message format MCP uses: tool discovery (tools/list) and tool execution (tools/call). Most MCP PHP libraries handle the protocol layer for you; your job is defining the tools and their handlers.

Permissions and traceability

This is the part that matters most in production, and it gets skipped in most MCP tutorials.

The MCP layer needs to respect the same authorization model as the rest of your application. A tool should not expose capabilities that the authenticated user doesn't have access to through the normal UI. If a warehouse operator can't run financial reports through the standard interface, they shouldn't be able to get them through the AI assistant either.

In practice, this means:

  • Tools receive the authenticated user's context (via the request or a passed actor)
  • Each tool handler checks permissions using the same policies and gates you've already defined
  • Tool calls are logged: who called what tool, with what parameters, and what was returned

That last point is important for regulated systems, but it's good practice everywhere. If the AI assistant dispatches a job or sends an email, that action needs to be attributable.

public function handle(array $input, User $actor): array
{
    $this->authorize($actor, 'view-financial-reports');

    // ... rest of handler
}

Same authorization logic, different call path.

What to expose (and what not to)

Not everything in your application should be an MCP tool. The surface you expose should be deliberate.

Good candidates for MCP tools:

  • Read-heavy analytical queries that require flexible parameterization
  • Report generation workflows (query -> transform -> render -> export)
  • Status checks and lookups across related entities
  • Notification and communication dispatchers
  • Workflow triggers that already exist as services or jobs

Bad candidates:

  • Raw database access without domain-level constraints
  • Irreversible destructive operations (unless you build explicit confirmation steps)
  • Anything that bypasses your existing validation and business rule layer

The principle is that MCP tools should wrap existing application logic, not expose infrastructure directly. A tool that calls app(InvoiceService::class)->generate() is correct. A tool that runs DB::statement() with arbitrary SQL is not.

Graph RAG as the knowledge complement

MCP handles the "do things" side. For the "know things" side (understanding relationships in your domain data, organizational knowledge, and document context), Graph RAG is the complement.

A language model calling MCP tools knows what actions are available. A language model with a Graph RAG index also understands that this invoice belongs to that supplier, which has this contract history, which links to these payment terms. The graph gives relational context that flat retrieval doesn't.

The combination (MCP for action, Graph RAG for knowledge) is what makes the AI layer feel like an informed participant in the system rather than a stateless query executor.

Both are buildable on top of a standard Laravel stack. PostgreSQL with pgvector handles the vector side of retrieval. Graph relationships come from your existing Eloquent relationships, formalized into a queryable structure.

When to add MCP

The right moment is when you have a working system with solid service boundaries and users are consistently hitting the ceiling of what the UI allows. That's the signal: recurring requests for custom reports, one-off queries being handled by developers, or data being exported to Excel for manual processing.

If you're designing a new system, include MCP tool boundaries in the architecture from the start, not as the primary interface, but as a designed layer with permissions and logging thought through before the first tool is written.

If you're adding it to an existing system, the refactor cost is low when services are already properly separated. The tools are thin wrappers. The work is identifying what to expose, not rewriting logic.

The stack hasn't changed. The paradigm has shifted slightly: your existing application services are now addressable in natural language, through a controlled surface, with the same authorization rules you've already written.

That's the addition. Everything else stays the same.

Thanks for reading.

Back to Journal

Stay Updated

Join the mailing list for technical discourse, architectural logs, and research notes. No spam, ever.