How to give memory to an AI assistant built with Laravel

The assistant works. It answers well, it calls your tools, the demo goes fine. Then the user comes back on Thursday and explains their situation again from the beginning, because on Tuesday it was listening and now it has never met them.

That is not a rough edge. It is the difference between a feature people use twice and one they rely on, and the fix is not a bigger context window: replaying every conversation the user has ever had costs more each time and eventually stops fitting.

What you want is smaller than that. Not the transcript, the handful of things the transcript established.

What a memory actually is

Three questions, and the mistake is answering them with one column.

whose is it        the firm, the workspace, the account
what is it about   a case, a project, or nothing in particular
who does it apply to   everyone here, or one person

Take "María prefers bullet points". Whose: the agency. What about: nothing in particular. Applies to: María. Now take "this firm bills fortnightly": same agency, nothing in particular, everyone. And "the other side never answers emails on this matter": the agency, that matter, everyone.

Three rows, three different shapes, one table:

Schema::create('memories', function (Blueprint $table) {
    $table->id();
    $table->nullableMorphs('tenant');      // whose
    $table->nullableMorphs('memorable');   // what about, null = the tenant as a whole
    $table->nullableMorphs('memory');      // who it applies to, null = everyone
    $table->text('content');
    $table->timestamps();
});

The middle one is the axis people skip, and skipping it means every memory belongs to the whole account, so something true of one project starts colouring answers about all of them.

The third one is the axis people get wrong for a more interesting reason. It reads like an access control problem and it is not. Showing María's preference to Juan is not a leak, it is worse: the model starts behaving with Juan as if he were María. Bullet points for everybody, because one person once asked. Nobody's data escaped and the product is still wrong.

Reading them back

$memories = Memory::query()
    ->where('tenant_type', $org->getMorphClass())
    ->where('tenant_id', $org->getKey())
    ->where(function ($q) use ($matter) {
        $q->whereNull('memorable_id');   // about the firm as a whole
        if ($matter) {
            $q->orWhere(fn ($s) => $s
                ->where('memorable_type', $matter->getMorphClass())
                ->where('memorable_id', $matter->getKey()));
        }
    })
    ->where(function ($q) use ($user) {
        $q->whereNull('memory_id');      // applies to everyone
        $q->orWhere(fn ($m) => $m
            ->where('memory_type', $user->getMorphClass())
            ->where('memory_id', $user->getKey()));
    })
    ->latest()->limit(50)->get();

Note the tenant clause is not optional decoration. Two accounts on one installation share the morph aliases and can share the ids behind them, so a row without a tenant filter is reachable from the next account's conversation. That one is a leak.

An autonomous run has no user, so memory_id never matches and only the shared rows come back. That is the safe reading and it falls out of the query rather than needing a special case: with nobody to scope to, nothing personal applies.

Putting them in the prompt

Keep the two groups apart:

## Remembered from earlier

### About this agency
- Bills fortnightly, on the 1st and the 15th.

### Applies to the current user
- Prefers bullet points.

Flatten them into one list and the model loses the only signal that tells a firm's fact from one person's preference, and starts applying the second as if it were the first. Two headings is the whole intervention.

Where the block goes matters too. Memories go below your own prompt, and anything the user must obey goes above it. A directive buried under a wall of remembered context stops reading as a directive.

Writing them is a separate job

The obvious implementation is to extract memories at the end of each turn, in the request. Do not.

It is another model call, so every reply now waits for a second round trip before it can be shown, and the user pays for remembering with latency on the message they just sent. Queue it:

class DistilMemories implements ShouldQueue
{
    public function handle(): void
    {
        $session = ChatSession::find($this->sessionId);
        // ask a cheap model what is worth keeping, write the rows
    }
}

When to queue it is the question with a non-obvious answer. Once per turn is wasteful: most turns establish nothing. Once per conversation needs an "ended" event that a chat does not have, because conversations are abandoned, not closed.

Two triggers cover it. The first is compression: when the replayed history outgrows its budget and you summarise the old turns, a substantial amount has been said, which is exactly when there is something worth keeping. The second is a scheduled sweep over sessions that have been quiet for thirty minutes, which catches conversations that stopped before ever growing big enough to compress. Without the second one you remember long conversations and forget short ones, and short ones are most of them.

Three things that will bite

Advance the checkpoint even when you write nothing. Store the last message id you read. Without it the sweep picks up the same session for ever, paying for a model call each time to conclude there is nothing new. Advance it when the extraction fails, too: retrying a window that just failed usually fails again, and each attempt is billed.

Lock the session. Compression and the sweep can fire on the same conversation at the same time, and two workers will each pay for a call and race to write the same rows. Cache::lock("distil:{$session->id}", 120) and the loser leaves; the winner's checkpoint means there was nothing left for it to do.

Give the model your vocabulary, not a package's. When you ask it which scope a memory belongs to, the options should be your own morph aliases: matter, firm, property. Then the answer is already the value that goes in the column, nothing needs translating, and nothing gets dropped for failing to translate.

Then let it go looking

The prompt carries the most recent memories and nothing older. So the model will confidently say it does not know something you wrote down in March, because March is not in the fifty rows you pasted.

Give it a tool:

public function description(): string
{
    return 'Search what you remember about this account and this user. '
         . 'Use it when the user refers to something from an earlier conversation.';
}

Ranking by recency is fine, and cheaper than it sounds. Semantic search over a few thousand rows in one account is a SELECT and a cosine loop in PHP, and you need embeddings, which cost money on every memory written. Start with recency plus the search tool, and reach for vectors when a scope genuinely outgrows it.


All of the above is laragents, which is where I ended up after writing it twice: Memory, MemoryDistiller, the two triggers, the lock, the checkpoint and a search_memories tool that ships registered. The memory chapter goes into the parts this post skipped, including how to let people opt out of the shared pool without retracting what is already stored.

written by Edu Lazaro · August 2026