Laravel Daily's

Give Your Laravel AI Agent a Memory: Conversation Persistence and Context Compaction with the AI SDK

Illustrated Laravel AI agent storing conversation memory in a database

An AI agent does not remember anything between requests unless your application sends that context again.

That distinction matters in every chat product. A stateless agent may answer the first message correctly, then lose the thread immediately:

User: My name is Priya. I use Laravel 13 and PostgreSQL.

Agent: Nice to meet you, Priya. I can help with Laravel and PostgreSQL.

User: What database should I use for vector search?

Agent: What framework and database are you using?

The failure is not cosmetic. Users repeat themselves, support agents lose trust, and product teams pay to resend irrelevant prompts. A reliable AI feature needs two separate systems:

  1. Durable conversation storage.
  2. A strategy for selecting which stored context reaches the model.

The Laravel AI SDK provides the first system. You must design the second.

Laravel ecosystem illustration for building AI agents with PHP

Why stateless prompting breaks: Memory is an application concern

Language models process the messages included in the current request. They do not automatically retrieve your previous HTTP requests, database records, or browser state.

A stateless controller often looks like this:

public function reply(Request $request): JsonResponse
{
    $response = (new SupportAgent)->prompt(
        $request->string('message')->toString()
    );

    return response()->json([
        'message' => (string) $response,
    ]);
}

This is fine for one-shot generation. It is not a conversation.

A persistent agent should treat the conversation as a resource with an identifier. The client sends that identifier with each request. The server loads the conversation, assembles context, prompts the agent, and stores the new turn.

Takeaway: persistence is not a prompt trick. It is part of your product’s data model.

Database-backed history: Let the SDK handle the first layer

Install the SDK, publish its migrations, and run them:

composer require laravel/ai

php artisan vendor:publish \
    --provider="Laravel\Ai\AiServiceProvider"

php artisan migrate

The migrations create:

  • agent_conversations
  • agent_conversation_messages

The first table stores conversation metadata and its participant. The second stores user messages, assistant messages, tool calls, attachments, usage, and related metadata.

An agent opts into database-backed conversation history by implementing Conversational and using RemembersConversations:

<?php

namespace App\Ai\Agents;

use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;

final class SupportAgent implements Agent, Conversational
{
    use Promptable;
    use RemembersConversations;

    public function instructions(): string
    {
        return <<<'TEXT'
        You are a concise support assistant.
        Use the conversation history when answering.
        Never invent account or billing details.
        TEXT;
    }
}

The trait loads previous messages before prompting and persists the user and assistant messages after the response.

Takeaway: use the built-in trait when the SDK’s default storage and context loading match your application.

Starting and continuing conversations: IDs and authorization

Start a new conversation with forUser:

$response = (new SupportAgent)
    ->forUser($user)
    ->prompt('I need help configuring queues.');

$conversationId = $response->conversationId;

Return $conversationId to your frontend. Store it in the URL, local state, or your own chat record.

Continue a specific conversation with continue:

$response = (new SupportAgent)
    ->continue($conversationId, as: $user)
    ->prompt('The worker is timing out after 60 seconds.');

If your product has one active thread per participant, use continueLastConversation:

$response = (new SupportAgent)
    ->continueLastConversation(as: $user)
    ->prompt('Continue from our previous discussion.');

There is an important security caveat. continue selects a conversation by ID. It does not automatically prove that the authenticated user owns that conversation. Check authorization before calling it:

use Illuminate\Support\Facades\Gate;
use Laravel\Ai\Models\Conversation;

public function show(Request $request, string $conversationId): JsonResponse
{
    $conversation = Conversation::query()->findOrFail($conversationId);

    Gate::forUser($request->user())
        ->authorize('view', $conversation);

    $response = (new SupportAgent)
        ->continue($conversation->id, as: $request->user())
        ->prompt($request->string('message')->toString());

    return response()->json([
        'conversation_id' => $response->conversationId,
        'message' => (string) $response,
    ]);
}

Your policy should check the participant, tenant, or workspace boundary:

public function view(User $user, Conversation $conversation): bool
{
    return $conversation->participant_type === $user->getMorphClass()
        && (string) $conversation->participant_id === (string) $user->getKey();
}

Takeaway: a conversation ID is an identifier, not an authorization decision.

Polymorphic participants: User and Team histories stay separate

Conversation participants are polymorphic. You can scope history to a User, a Team, or another model:

(new SupportAgent)
    ->forParticipant($team)
    ->prompt('Summarize this workspace activity.');

The SDK stores both the participant’s morph type and primary key. That distinction prevents collisions:

App\Models\User:1
App\Models\Team:1

Those are different participants, even though both records have primary key 1.

This is useful for team assistants. A user may have private preferences, while the team shares project decisions and deployment conventions.

Takeaway: choose the participant deliberately. User memory and workspace memory should not be interchangeable.

The messages() gotcha: Trait behavior is easy to override

When you use RemembersConversations, do not define your own messages() method.

The trait provides automatic context loading. A method with the same name in your agent overrides that behavior:

// This disables the trait's automatic conversation loading.
public function messages(): iterable
{
    return [];
}

That may look harmless during refactoring. The result is an agent that still writes messages but stops reading them back.

If you need to control the context query, remove RemembersConversations and implement Conversational yourself.

Takeaway: automatic persistence and custom context assembly are different modes. Do not mix them accidentally.

Custom storage: Redis and other backends

The Conversational contract lets you keep history outside the SDK’s database tables. This is useful for Redis, DynamoDB, an event store, or a tenant-specific conversation service.

A simplified Redis-backed agent can expose messages like this:

<?php

namespace App\Ai\Agents;

use Illuminate\Support\Facades\Redis;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;

final class RedisSupportAgent implements Agent, Conversational
{
    use Promptable;

    public function __construct(
        private readonly string $conversationId,
    ) {}

    public function instructions(): string
    {
        return 'You are a helpful support assistant.';
    }

    public function messages(): iterable
    {
        return collect(Redis::lrange($this->key(), 0, -1))
            ->map(fn (string $value) => json_decode($value, true))
            ->map(fn (array $message) => new Message(
                $message['role'],
                $message['content'],
            ))
            ->all();
    }

    public function append(string $role, string $content): void
    {
        Redis::rpush($this->key(), json_encode([
            'role' => $role,
            'content' => $content,
        ]));
    }

    private function key(): string
    {
        return "ai:conversation:{$this->conversationId}";
    }
}

Your application must persist the user message and final assistant response around the prompt. In production, also store tool calls, failures, timestamps, usage, and provider metadata.

Takeaway: custom storage gives you control, but you also inherit consistency, retention, and replay responsibilities.

Context windows: Stored history is not usable history

The default trait can load a bounded number of messages, but long-running conversations still need a compaction policy. The model has a finite context window.

Common strategies include:

Message windowing

Keep the latest N messages:

protected function maxConversationMessages(): int
{
    return 40;
}

This is simple and predictable. It can lose important decisions from earlier turns.

Token-based trimming

Estimate tokens and remove the oldest turns until the prompt fits. Reserve space for system instructions, tools, the current request, and the response.

Character-based estimates are imperfect. Provider usage data is better when available.

Summarizing older turns

Send older messages to a summarizer and store a durable summary containing:

  • User preferences.
  • Decisions.
  • Open tasks.
  • Important identifiers.
  • Constraints and rejected approaches.

Then send the summary plus recent turns.

Hybrid compaction

Keep recent messages verbatim, summarize older messages, and retrieve only relevant durable facts.

The open Laravel AI SDK issue #669 proposes first-class support for message windowing, token trimming, summarization, hybrid strategies, and custom compaction implementations. Until that support lands, applications must own this layer.

Community projects such as keethus/laravel-ai-memory focus on scoped fact memory with embeddings and pgvector. mabou7agar/laravel-ai-support is also referenced in discussions around context compaction and configuration such as:

AI_AGENT_CONTEXT_MAX_MESSAGES=100
KEEP_RECENT_MESSAGES=12

Verify the package names, APIs, and maintenance status before adding them to a production application.

Illustration of an overflowing conversation being compacted into summaries and recent turns

A pragmatic production pattern: Three memory tiers

A useful design keeps three tiers:

  1. Recent turns: the last few exchanges, unchanged.
  2. Conversation summary: a durable summary of older discussion.
  3. Fact memory: small, independently retrievable facts.

A agent_memories table might include:

Schema::create('agent_memories', function (Blueprint $table) {
    $table->id();
    $table->string('participant_type');
    $table->string('participant_id');
    $table->string('kind')->default('fact');
    $table->text('content');
    $table->json('metadata')->nullable();
    $table->vector('embedding', dimensions: 1536)->nullable();
    $table->timestamps();

    $table->index([
        'participant_type',
        'participant_id',
    ]);
});

Use Laravel’s embedding support and PostgreSQL pgvector to retrieve relevant facts:

$memories = AgentMemory::query()
    ->where('participant_type', $user->getMorphClass())
    ->where('participant_id', $user->getKey())
    ->whereVectorSimilarTo('embedding', $request->message)
    ->limit(5)
    ->get();

Inject only those facts into the agent’s instructions or context. Do not turn every remembered detail into a permanent system instruction.

Takeaway: durable memory should be selective, scoped, and retrievable.

Exposing the agent as a REST API

A REST endpoint can accept a nullable conversation ID. It starts a thread when the ID is absent and continues one when present:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Laravel\Ai\Models\Conversation;

public function chat(Request $request): JsonResponse
{
    $data = $request->validate([
        'conversation_id' => ['nullable', 'string'],
        'message' => ['required', 'string', 'max:12000'],
    ]);

    $agent = new SupportAgent;

    if ($data['conversation_id']) {
        $conversation = Conversation::query()
            ->findOrFail($data['conversation_id']);

        Gate::forUser($request->user())
            ->authorize('view', $conversation);

        $agent = $agent->continue(
            $conversation->id,
            as: $request->user(),
        );
    } else {
        $agent = $agent->forUser($request->user());
    }

    $response = $agent->prompt($data['message']);

    return response()->json([
        'conversation_id' => $response->conversationId,
        'message' => (string) $response,
    ]);
}

Register it as a normal API route:

Route::middleware('auth:sanctum')
    ->post('/ai/conversations', [ChatController::class, 'chat']);

For streaming, replace prompt with stream and return the SDK response directly:

return $agent
    ->stream($data['message'])
    ->usingVercelDataProtocol();

That gives a Vue, React, or native client incremental output while the SDK persists the completed response. This is a practical way to build a REST API with PHP without moving agent orchestration into a separate service.

Takeaway: return the conversation ID on the first response. The client needs a stable handle for every later request.

Testing: Assert memory, not just prose

Use deterministic fakes. The official SDK supports faking an agent:

SupportAgent::fake([
    'Your queue worker should use a supervisor.',
]);

$response = (new SupportAgent)
    ->forUser($user)
    ->prompt('How should I run this worker?');

SupportAgent::assertPrompted('How should I run this worker?');

$this->assertSame(
    'Your queue worker should use a supervisor.',
    (string) $response,
);

Provider fakes prevent network calls during tests. If your memory package exposes AgentMemory::fake(), fake that store as well:

AgentMemory::fake([
    ['content' => 'The user prefers JSON responses.'],
]);

$response = $this->postJson('/api/ai/conversations', [
    'message' => 'Return the result in my preferred format.',
]);

$response->assertOk();

For custom context assembly, test the assembled messages directly. Assert that recent turns remain, summaries appear, unrelated memories stay out, and another user’s facts never cross the scope boundary.

Takeaway: a memory test should verify the context sent to the model, not only the model’s final wording.

Clear takeaway: Persistence is only the beginning

The Laravel AI SDK makes conversation persistence straightforward. RemembersConversations, forUser, continue, polymorphic participants, and streaming responses cover the foundation.

Long-running agents need more. Keep the raw transcript for auditability, keep recent turns for precision, summarize older context for continuity, and store durable facts for retrieval.

That separation gives your Laravel application memory without forcing every past message into every prompt.

Previous
The Back Button Is Not Your Enemy: History and State Preservation in Inertia 3.x with Laravel + Vue
Next
i18n in Laravel + Vue + Inertia 3.x: Ship a Multilingual SPA Without the Bloat