Matthew Hodge
Full Stack Developer

Building AI Agents in Laravel with Prism

AI features have gone from "interesting experiment" to something clients are genuinely asking for. The challenge as a Laravel developer has been that the official SDKs from Anthropic, OpenAI, and others are PHP-first but not Laravel-first — you end up writing a lot of plumbing yourself.

Prism solves this. It's a Laravel-native AI SDK that wraps multiple providers (Anthropic, OpenAI, Gemini, Mistral, and others) behind a consistent, fluent API. Provider swaps are a config change, tool-use and agents work the same way regardless of which model you're using, and it slots into Laravel's service container naturally.

In this post, I'll walk through installing Prism, generating text, working with structured output, and building a practical agent with tools.


Installation

composer require echolabs/prism
php artisan vendor:publish --tag=prism-config

This creates config/prism.php. Add your provider credentials to .env:

ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...

Configure your default provider and model in config/prism.php:

'default_provider' => env('PRISM_PROVIDER', 'anthropic'),
'default_model'    => env('PRISM_MODEL', 'claude-sonnet-4-6'),

Simple Text Generation

The most basic use case — generate text from a prompt:

use EchoLabs\Prism\Facades\Prism;
use EchoLabs\Prism\Enums\Provider;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withPrompt('Explain what a Laravel service provider does in two sentences.')
    ->generate();

echo $response->text;

Or using your configured defaults:

$response = Prism::text()
    ->withPrompt('What is the difference between a Job and an Event in Laravel?')
    ->generate();

echo $response->text;

System Prompts and Messages

For anything beyond a single prompt, you'll want a system message and a message history. This is how you shape the model's persona or context:

use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a helpful assistant specialising in Laravel and PHP. Keep responses concise and practical.')
    ->withMessages([
        new UserMessage('How do I eager load relationships in Laravel?'),
        new AssistantMessage('Use `with()` on your query: `User::with("posts")->get()`...'),
        new UserMessage('What about nested relationships?'),
    ])
    ->generate();

echo $response->text;

This is the foundation of building a multi-turn chat feature.


Structured Output

Instead of parsing plain text, you can tell the model to return structured data matching a schema. Prism handles the JSON extraction and validation:

use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\ArraySchema;

$schema = new ObjectSchema(
    name: 'ticket_triage',
    description: 'Triage result for a support ticket',
    properties: [
        new StringSchema('priority', 'Priority level: low, medium, high, or critical'),
        new StringSchema('category', 'Issue category: billing, technical, account, or other'),
        new StringSchema('summary', 'One-sentence summary of the issue'),
        new ArraySchema('tags', 'Relevant tags', new StringSchema('tag', 'A tag')),
    ],
    requiredFields: ['priority', 'category', 'summary']
);

$response = Prism::structured()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a support ticket triage assistant.')
    ->withPrompt('Ticket: "I can\'t log in, it says my password is wrong but I just reset it 5 minutes ago."')
    ->withSchema($schema)
    ->generate();

$result = $response->structured;
// [
//     'priority' => 'high',
//     'category' => 'account',
//     'summary'  => 'User cannot log in despite recently resetting their password.',
//     'tags'     => ['authentication', 'password-reset', 'login'],
// ]

You get a typed, validated array back — ready to store in the database or act on directly.


Building an Agent with Tools

This is where Prism gets genuinely interesting. Agents are models that can call tools (PHP functions you define) to take actions or retrieve information, then use the results to continue reasoning.

Defining Tools

use EchoLabs\Prism\Tool;

$getOrderStatus = Tool::as('get_order_status')
    ->for('Retrieve the current status of a customer order by order ID')
    ->withStringParameter('order_id', 'The order ID to look up')
    ->using(function (string $order_id): string {
        $order = Order::find($order_id);

        if (! $order) {
            return "Order {$order_id} not found.";
        }

        return "Order {$order_id}: status={$order->status}, placed={$order->created_at->toDateString()}, total=\${$order->total}";
    });

$getCustomerOrders = Tool::as('get_customer_orders')
    ->for('List recent orders for a customer by their email address')
    ->withStringParameter('email', 'The customer email address')
    ->using(function (string $email): string {
        $customer = Customer::where('email', $email)->first();

        if (! $customer) {
            return "No customer found with email {$email}.";
        }

        $orders = $customer->orders()->latest()->limit(5)->get();

        if ($orders->isEmpty()) {
            return "Customer {$email} has no orders.";
        }

        return $orders->map(fn($o) => "#{$o->id} - {$o->status} - \${$o->total} - {$o->created_at->toDateString()}")->join("\n");
    });

Running the Agent

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a customer support agent for an e-commerce store. Use the tools available to look up order and customer information before answering questions.')
    ->withPrompt('Can you check on my recent orders? My email is jane@example.com')
    ->withTools([$getOrderStatus, $getCustomerOrders])
    ->withMaxSteps(5)
    ->generate();

echo $response->text;
// "I found your recent orders, Jane! Here's what I can see:
//  - Order #1042 — completed — $89.99 — March 10
//  - Order #1038 — shipped — $234.00 — February 28
//  ..."

withMaxSteps controls how many tool call rounds the agent can make. The model calls a tool, gets the result, decides whether to call another tool or respond to the user, and keeps going until it has an answer or hits the step limit.


A Practical Example: Article Tag Suggester

Here's a complete, practical example — a class that takes a blog post body and suggests tags using structured output:

// app/AI/ArticleTagger.php

namespace App\AI;

use EchoLabs\Prism\Facades\Prism;
use EchoLabs\Prism\Enums\Provider;
use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\ArraySchema;

class ArticleTagger
{
    private ObjectSchema $schema;

    public function __construct()
    {
        $this->schema = new ObjectSchema(
            name: 'article_tags',
            description: 'Suggested tags and metadata for a blog article',
            properties: [
                new ArraySchema('tags', 'Suggested tags (3-6)', new StringSchema('tag', 'A tag')),
                new StringSchema('primary_category', 'The primary technical category'),
                new StringSchema('difficulty', 'Difficulty level: beginner, intermediate, or advanced'),
            ],
            requiredFields: ['tags', 'primary_category', 'difficulty']
        );
    }

    public function suggest(string $title, string $body): array
    {
        $response = Prism::structured()
            ->using(Provider::Anthropic, 'claude-haiku-4-5-20251001') // cheaper model for this task
            ->withSystemPrompt('You are a technical blog editor. Analyse the article and return appropriate tags and metadata.')
            ->withPrompt("Title: {$title}\n\n{$body}")
            ->withSchema($this->schema)
            ->generate();

        return $response->structured;
    }
}

Use it in a controller or action:

$tagger  = new ArticleTagger();
$result  = $tagger->suggest($post->title, $post->body);

$post->update([
    'tags'     => $result['tags'],
    'category' => $result['primary_category'],
]);

Token Usage and Cost Tracking

Prism exposes token usage so you can track costs:

$response = Prism::text()
    ->withPrompt('Summarise this article...')
    ->generate();

$usage = $response->usage;

Log::info('AI usage', [
    'input_tokens'  => $usage->promptTokens,
    'output_tokens' => $usage->completionTokens,
]);

Store this in a database table if you want per-user or per-feature cost reporting.


Swapping Providers

One of Prism's best features is that your application code doesn't change when you swap providers. If you want to test OpenAI vs Anthropic, change the provider in the using() call — the rest stays the same:

// Was: Provider::Anthropic, 'claude-sonnet-4-6'
// Now:
->using(Provider::OpenAI, 'gpt-4o')

Or make it a config value and switch via .env:

->using(
    config('prism.default_provider'),
    config('prism.default_model')
)

Final Thoughts

Prism lowers the barrier to adding AI features to a Laravel app significantly. The fluent API is familiar, tools make agent-style features practical without a lot of boilerplate, and switching providers is trivial.

Start with something small — a summariser, a tag suggester, a support ticket classifier — and get comfortable with the request/response cycle. Once you've got that pattern down, building more sophisticated agents with multiple tools is a natural extension.

The Laravel and AI space is moving fast, so keep an eye on the Prism docs for new provider support and features.