edulazaro / larachat
A website chat for Laravel that a bot answers and you can take over: retrieval over your own pages (JSON or Meilisearch), a cheap model that turns away off-topic questions before the expensive one is called, deterministic guards against prompt injection and abuse, lead capture, a private inbox to re
Requires
- php: >=8.2
- laravel/framework: >=12.0
- livewire/livewire: ^3.5
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
Suggests
- edulazaro/larameili: Required for the meilisearch knowledge driver (larachat.knowledge.driver = 'meilisearch'). The json driver needs nothing.
Provides
None
Conflicts
None
Replaces
None
README
Larachat - a website chat for Laravel that a bot answers and you can take over
A chat for the public pages of a Laravel site. A bot answers from your own pages, a cheap model turns away questions that are not for this chat before the expensive one is called, deterministic guards stop prompt injection and abuse without spending a token, and when a visitor asks for a person the conversation lands in a private inbox where you answer, from the panel or from Telegram.
The package owns the parts that are the same on every site and expensive to get right: the pipeline, the guards, retrieval, lead capture, the panel and the alerts. You write who the bot is (a persona) and how the widget looks.
What happens to a message
- Guards, no API call. A conversation closed for abuse, a message with nothing in it ("x", "???", letters one by one), a prompt-injection attempt or a request for code gets a fixed answer. Injection strikes close the conversation for a day, and so do a few empty messages in a row (any real message starts that count again).
- Scope filter and embedding, in parallel. A cheap model (
filter_model) decides whether the message is for this chat at all, while the query is embedded. Off-topic gets a fixed answer and the main model is never called; a few of those (per conversation AND per IP) close the chat for a day. - Retrieval. Hybrid search (keywords plus vectors) over the chunks of your site in the visitor's language, at most two chunks per page, plus the first chunks of the page the visitor is on.
- The reply. The main model answers as JSON: the reply, whether the visitor asked for a person, whether they showed commercial intent, and any contact details they gave. The fixed part of the prompt (your persona, the rules, the site map) sits behind a prompt-cache breakpoint, so after the first message in 30 minutes it is read at a twentieth of the price.
- After the reply. Unsafe replies (code, quoted prompt sections) are replaced and count as a strike. Contact details written in a message are kept only if valid AND literally in what the visitor wrote. A handoff goes to you only if you are at the panel; otherwise the visitor gets the contact form.
Getting the visitor's details
The model never asks for them. A line of text asking once is easy to ignore, and a visitor
who asks for a price and says goodbye leaves with nothing. The package decides, in code, when
to ask, and asks with a contact form placed in the conversation: a message of its own
(kind contact_form) that the widget draws as a form, which stays where it is until it is
filled and survives a reload.
It is placed when the visitor:
- asks to be called, contacted or written to, or asks for the form itself;
- shows real interest (the model's
qualified), the first time; - asks about prices, budgets or timelines (in English, Spanish, Catalan or French);
- says goodbye after showing interest;
- closes the chat after showing interest (the bubble then invites them to leave their details);
- asks for a person who is not at the panel, or who has not answered within a few seconds.
At most lead.max_forms times per conversation (3), and never two at once: a new one replaces
the one still empty. Each has a title that says why (So Edu can send you a quote). The form
asks for the fields in lead.required (name and email) and an optional phone. Characters that
cannot belong in a field never appear in it (letters for the name, digits and separators for
the phone, no spaces in the email), the format is checked before sending, and the server
applies the same rules. Sent, it
becomes the visitor's own message (Here are my details: Laura · laura@acme.test, kind
contact, the values in its meta), the conversation keeps them, the bot thanks them by name
and you get the lead alert.
The first sign of interest also sends you an alert while the visitor is still there, and the panel's Interested, no details filter lists the conversations that showed interest and left no way to reach them.
Prices: the bot never makes up a figure. It quotes only prices written in your persona's prompt (a product with published plans, say); for anything else it says you will prepare a quote, and the form appears.
If OpenAI or the store fails, the reply falls back to the persona's fallback knowledge, so the chat never goes silent. Long conversations are summarised by the cheap model.
Install
composer require edulazaro/larachat php artisan vendor:publish --tag=larachat-config php artisan vendor:publish --tag=larachat-migrations php artisan migrate php artisan larachat:admin you@example.com
The migration creates larachat_sessions and larachat_messages, or completes them if they
already exist, and adds is_available and last_seen_at to users. It never changes
existing data, so after an upgrade that adds columns you publish it again and migrate: it only
adds what is missing.
Moving a site whose chat larachat was extracted from (tables chat_sessions and
chat_messages)? Rename them in a migration of your own dated before larachat's, and the
conversations carry over:
Schema::rename('chat_sessions', 'larachat_sessions'); Schema::rename('chat_messages', 'larachat_messages');
If your web server answers .js and .css requests itself (a common nginx setup, which
returns its own 404 for a file that is not on disk), the routes that serve the widget script,
the panel's CSS and Livewire's own script are never reached. Publish those files to public/
on every install, and the package and Livewire use the copies:
"post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi", "@php artisan vendor:publish --tag=livewire:assets --force --ansi", "@php artisan vendor:publish --tag=larachat-assets --force --ansi" ]
Add public/vendor/livewire and public/vendor/larachat to .gitignore: they are copies.
Add your email to admins in config/larachat.php, set OPENAI_API_KEY, index the site
(below) and put the widget in your layout:
@include('larachat::widget')
Configuration
Everything lives in config/larachat.php; the only environment variables are the secrets
(OPENAI_API_KEY, and the Telegram ones if you use it). The parts to change on every site:
| Key | What it is |
|---|---|
persona |
Your persona class (below) |
locales, default_locale |
The site's locales and the one served without a URL prefix |
routes.login, routes.panel |
Both larachat by default (one address). Change them: the package is public, so its defaults are too |
admins |
The emails allowed into the panel, whatever other users exist |
knowledge.driver |
json (no server) or meilisearch |
knowledge.base_url, knowledge.content, knowledge.drop |
Your public URL, the element that holds each page's content, what to remove inside it |
limits.replies_per_day |
Model replies per day for the whole site (500): the ceiling on what the chat can cost. Past it the bot answers with a fixed text and the contact form until midnight, and you get one email |
lead.max_forms, lead.required |
How many contact forms per conversation (3) and the fields they require (name, email) |
notifications |
Email to send alerts to; Telegram on or off |
widget.storage_key |
Where the browser keeps the visitor's token (keep the old key when moving a site) |
The persona
Extend EduLazaro\Larachat\Persona and point larachat.persona at it:
use EduLazaro\Larachat\Persona; class AcmePersona extends Persona { protected string $name = 'Ada'; // the bot protected string $owner = 'Laura'; // who takes over protected string $site = 'Acme'; // the business protected ?string $email = 'hello@acme.test'; public function systemPrompt(string $locale): string { return 'You are Ada, the AI assistant of Acme... (identity, scope, what you do, house rules)'; } public function scopePrompt(): string { return 'You are the gatekeeper of the chat on acme.test... Reply only with JSON: {"on_topic": true} or {"on_topic": false}'; } // Optional: siteMap(), fallbackKnowledge(), pageContext(), privacyUrl() }
The package adds the lead-capture rules and the output format to your system prompt, so a
persona cannot break the JSON the pipeline reads. Keep systemPrompt() identical for every
message in a locale: it is what gets cached.
Fixed texts (refusals, the closing message, the contact request) come from the package's
lang files in English, Spanish, Catalan and French, with :bot, :owner, :site and
:email filled in from the persona. Override any of them in
lang/vendor/larachat/{locale}/chat.php (php artisan vendor:publish --tag=larachat-lang).
Knowledge
php artisan larachat:index # render, chunk, embed what changed, drop what is gone php artisan larachat:index --dry-run # render and chunk only php artisan larachat:index --fresh # embed everything again php artisan larachat:index --path=/es/contact
Every URL of the sitemap (knowledge.sitemap: a file in public/ or a path the app serves;
a sitemap index is followed one level) is rendered through the HTTP kernel, so the bot reads
exactly what visitors see. The content element is cut into sections by heading and packed
into chunks of about 1,400 characters. Only chunks whose content changed are embedded again,
so an unchanged site costs nothing. Pages with an article:published_time in the future are
indexed but not used until that date. Schedule it nightly and run it after each deploy.
json driver: the chunks live in a PHP file under storage/ that opcache keeps compiled
in memory, so loading it costs nothing per request; vectors are packed binary. Fine for a few
thousand chunks.
meilisearch driver: composer require edulazaro/larameili, configure its connection,
and the chunks go to an index named knowledge.meilisearch.index (with larameili's prefix),
searched with Meilisearch's own hybrid search. Vectors are computed by the app, so Meilisearch
never holds an OpenAI key.
The widget
@include('larachat::widget')
One widget, the same on every site: a launcher in the corner (a round button with a pulse and
sparks) and a panel above it with the bot's name and the site, the conversation, the contact
form when it is time, and a message box that grows with the text (Enter sends, Shift+Enter
breaks the line). On phones the panel takes the screen, and with <html data-bs-theme="dark">
(Bootstrap's dark mode) it turns dark. What a site sets:
'widget' => [ 'colors' => ['primary' => '#2563eb', 'primary_dark' => '#1d4ed8'], 'radius' => 16, // the panel's corners; bubbles and fields follow 'owner_avatar' => '/img/team/me.webp', // your replies; without it, your initial 'launcher' => null, // or a Blade view of your own (below) ],
Colours that are not a colour and a radius that is not a number of pixels are ignored. The
texts (greeting, header, placeholder, the bubble) come from the package's lang files and are
overridden in lang/vendor/larachat/{locale}/widget.php.
A launcher of your own. Point widget.launcher at a Blade view and it replaces the
standard button; everything else stays the standard widget. The view needs an element with
data-lc="launcher" (it opens the chat) and may have one with data-lc="badge" (the unread
count, which gets .is-visible). The root gets .lc-open while the chat is open, if you want
to hide your launcher then:
<button type="button" class="my-orb" data-lc="launcher"> <canvas></canvas><span data-lc="badge">0</span> </button>
The script and the stylesheet are served by the package with a versioned URL, so an update
reaches the site without publishing anything (or from public/vendor/larachat when they are
published, see Install). The widget survives wire:navigate; wrap it in @persist('larachat')
to keep it open across pages.
The panel
/larachat shows the login to anyone who is not an admin and the inbox to you;
/larachat/profile changes your name, email and password (changing the email asks for the
current password and only takes an address in admins; a new password needs 12 characters). Give routes.login a path of its
own and the login moves there, while the panel answers 404 to guests and to users who are not
in admins, so only the login page shows there is one. Every panel page sends noindex, and
each action is checked against admins again, not only the page load. The login answers a
non-admin exactly as a wrong password and in the same time, is rate-limited per email and IP
and per email alone, and the password never travels back in Livewire's state. The widget sends
its token in a header when it polls, never in a URL that would end up in access logs.
The inbox lists every conversation with at least one visitor message, filters them (unread,
leads, with you, blocked), and lets you answer, take a conversation from the bot or hand it
back, block it (the visitor gets a closing message and their IP cannot open another one for
ip_block_days) or delete it (the visitor's widget starts again from scratch; deleting a
blocked conversation lifts the IP block). It beeps on new messages and, with the tab hidden,
updates the title and shows a browser notification.
The availability switch is what the bot reads before handing a conversation over. It only counts while the panel has been seen open in the last few minutes, so a forgotten switch turns itself off instead of leaving visitors waiting for nobody.
If you forget the password, run php artisan larachat:admin you@example.com again.
Alerts
Email (notifications.mail.to): one message per new conversation, one when a visitor
first shows interest and has left no details yet (so you can step in), and one per lead, sent
after the response. These alerts are capped per visitor IP and day
(notifications.mail.per_ip_per_day, 10), by email and Telegram, so a script opening
conversations cannot flood your inbox; conversations you are in are never capped. Details are
only accepted through a form the chat placed, once per form, so invented leads cannot be posted
straight to the API.
Telegram: set enabled, the bot token, your chat id and a webhook secret, then
php artisan larachat:telegram set. You get new conversations, leads, handoffs and every
visitor message while a conversation is yours; with all_messages also every exchange with
the bot. Answer with Reply on a forwarded message; /ai 123 or the buttons hand a
conversation either way. Only your chat is obeyed, and updates without the secret are refused
(there is no unprotected mode: without it, anyone who knew your chat id could reply to
visitors as you).
No websockets, no queue
Everything runs on the plain request cycle, so there is no Reverb, Pusher or queue worker to run:
- The widget polls for new messages once a conversation exists: every 3 seconds, slowing to
10 and then 15 seconds while nothing arrives, and back to 3 as soon as something does. Each
poll asks only for messages after the last one it has, and is rate-limited
(
limits.poll_per_minute). - The panel refreshes with
wire:pollevery 5 seconds, which is also the heartbeat the availability switch reads. - The bot answers within the visitor's request; emails and Telegram messages go out after the response has been sent.
Testing
composer install vendor/bin/phpunit
Sponsors
Larachat is supported by the following sponsors. Thank you for keeping it growing:
Author
Created by Edu Lazaro
License
Larachat is open-sourced software licensed under the MIT license.
