The data model
Seven tables. Every one of them names the thing it is about with an -able and says what everything else is, so reading this once makes the rest of the package predictable.
laragents_agents the standing instruction
laragents_agent_tasks one run of one agent
laragents_agent_logs what happened during it
laragents_chat_sessions one conversation
laragents_chat_messages its turns, tool plumbing included
laragents_memories what is remembered between conversations
laragents_rules what must be obeyed within them
The polymorphic columns
| Column | Holds | In a law firm |
|---|---|---|
chat_sessions.tenant |
whose conversation this is, and who the usage is charged to | the firm |
chat_sessions.actor |
who is talking | the lawyer |
chat_sessions.sessionable |
what the conversation is about, or null | the case |
memories.tenant |
whose memory it is | the firm |
memories.memorable |
what it is about, or null for the tenant as a whole | the case |
memories.memory |
who it applies to, or null for everyone in that scope | the lawyer, a team |
rules.tenant / .memorable / .memory |
the same three | |
agents.tenant |
whose agent it is | the firm |
agent_tasks.taskable |
what the run is about, or null | the case |
All of them are two columns, _type and _id, and the package never learns what they point at. The values in _type are your morph aliases: a scope you call matter is addressed as "matter" everywhere, including in the prompt the distiller sends the model.
None of them carries a foreign key, because a morph cannot. Deleting a tenant does not cascade, which is worth knowing before somebody asks what you still hold about them.
laragents_chat_sessions
ulid public identifier, generated on create
tenant, actor, sessionable
agent_id set when the session is an agent's run
title, pinned, pinned_at
total_tokens read mirror, for showing cost next to a conversation
anonymize_pii per-conversation privacy, default true
compressed_summary the older turns, once
compressed_until_id how far the summary reaches
distilled_until_id how far memory extraction has read
last_distilled_at
metadata
The two watermarks are separate on purpose, because they advance for different reasons: compression is driven by the token budget, distillation by how much has been said since last time. A conversation can be compressed and never distilled, or the reverse.
laragents_chat_messages
session_id, role, content, metadata
role is a 20-character string rather than an enum, so a provider inventing a new one does not need a migration. The tool plumbing lives in metadata: tool_calls on an assistant turn, tool_name and tool_call_id on a tool result.
Indexed on (session_id, id), which is the only way it is ever read.
laragents_memories
tenant, memorable, memory
created_by audit only, never decides visibility
agent_id which agent wrote it, null on delete
source 'distilled', 'explicit', or whatever you invent
category
content
embedding json, null when embeddings are off
metadata
created_by and memory are different things and the migration says so: one is who wrote it, the other is who it belongs to. An agent writing a memory about a client is neither.
agent_id is null on delete: what was learned outlives the thing that learned it.
embedding is JSON, not a vector type, because scoring happens in PHP over a bounded pool. No extension, no vector database, and a real ceiling in the low thousands per scope.
Indexed on (tenant_type, tenant_id, created_at), because selection is "the newest N for this tenant". It leads with the tenant and not the memorable because most rows have no memorable: they are about the tenant as a whole. The narrower lookup rides the index nullableMorphs already made.
laragents_rules
The same three axes, plus:
content
priority ascending, 0 first, ties break by id
enabled off rather than deleted
laragents_agents
ulid, tenant, created_by
name, avatar, specialty
system_prompt
status free string, compared against config
schedule_cron
event_triggers json, your event names
allowed_tools json, null = whatever the caller passes
config json, the bag
tasks_completed, tasks_failed, total_tokens_used
last_run_at
deleted_at soft delete
status is a free string and the package compares it against agents.active_status. Comparing it against a word of ours would quietly turn it into an enum, and an application that writes enabled or live would get no agents firing and no error to explain it.
avatar exists because an agent with no face, in a list next to people, reads as something broken.
Soft deleted, because tasks and logs point here and hard deleting would orphan the record of everything it ever did.
laragents_agent_tasks
agent_id, taskable
trigger_type 'event' or 'schedule'
trigger_event your event name
status pending, running, completed, failed
input, output json
input_tokens, output_tokens, credits_consumed
started_at, completed_at, error_message, metadata
The token columns are a read mirror, for showing a run's cost beside it. They are not the accounting: that belongs to whatever implements UsageRecorder. Two places holding the same number is a smell, and the alternative is joining an application-owned table to render a list.
The one migration that touches your tables
php artisan vendor:publish --tag=laragents-optout-migration
Adds share_memories to your users table, nullable, three-state. It is a separate tag because it is the only one that touches a table the package does not own, it asks which table rather than guessing, and it skips a column that is already there.