Add an AI chat to a Laravel app
Install apartejs/laravel and put a streaming chat in a Blade, Livewire or Filament page, wired to the Laravel AI SDK, with tool calls a person can approve and no JavaScript build step.
apartejs/laravel is a Composer package that ships the chat
already built and connects it to the Laravel AI SDK. Your app needs
no React and no front-end build, and the model runs on your server, so API keys stay in .env. Replies
stream with Markdown, tool calls show as they run, and a person can approve or refuse them. To add
aparté’s plugins, you build the chat with your own Vite: see Add plugins.
It needs PHP 8.3+, Laravel 12 or 13 and laravel/ai 1.3+.
Install
Section titled “Install”composer require apartejs/laravel
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrateThe last two are the SDK’s own setup: its configuration and the tables that keep conversations.
Render the chat
Section titled “Render the chat”An agent that keeps its conversations:
namespace App\Ai\Agents;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;
class Assistant implements Agent, Conversational
{
use Promptable, RemembersConversations;
public function instructions(): string
{
return 'You are a helpful assistant.';
}
}A route, and the chat in a view. Give it a height:
// routes/web.php
Route::aparte('/chat', App\Ai\Agents\Assistant::class);<x-aparte::chat endpoint="/chat" style="height: 32rem" />Inside a Livewire component it works as is: it is excluded from Livewire’s re-renders, and a
conversation survives wire:navigate.
The route spends your model budget and runs your tools, so give it the middleware your app needs:
Route::aparte('/chat', App\Ai\Agents\Assistant::class)->middleware(['auth', 'throttle:20,1']);A button in the corner of the page
Section titled “A button in the corner of the page”<x-aparte::launcher endpoint="/chat" label="Help" />Filament
Section titled “Filament”The plugin works with Filament 4 and 5:
use Aparte\Laravel\Filament\ApartePlugin;
$panel->plugin(ApartePlugin::make()->agent(App\Ai\Agents\Assistant::class)->label('Assistant'));Every page of the panel gets the button, for whoever is signed in to it. The route is added for you, behind the panel’s authentication, and the chat follows the panel’s light or dark mode.
Approve tool calls
Section titled “Approve tool calls”A tool that needs a person uses the SDK’s approvals:
public function tools(): iterable
{
return [(new SendInvoice)->requireApproval('Sending an invoice.')];
}The chat asks before the tool runs. The model answers a refusal, and reads what the person typed in its place. A pending question comes back after a reload.
<x-aparte::chat endpoint="/chat" style="height: 32rem">
<x-slot:empty-state><p>How can I help?</p></x-slot:empty-state>
<x-slot:above-composer><p>Answers may be wrong.</p></x-slot:above-composer>
<x-slot:toolbar><button type="button" style="margin-inline-start: auto">Help</button></x-slot:toolbar>
</x-aparte::chat>Add plugins
Section titled “Add plugins”The built chat draws Markdown, tool calls and approvals. To add aparté’s plugins, such as cards, maths, diagrams, charts or code highlighting, or cards of your own, build the chat with your app’s Vite instead. The plugins and the chat then share one copy of aparté, which a plugin installed beside the built chat would not.
Tell the package to stop serving its own copy, in .env:
APARTE_ASSETS=viteInstall the chat and the plugins you want:
npm install @aparte/core@^1 @aparte/runner-ag-ui@^1 @aparte/plugin-marked@^1 marked
npm install @aparte/plugin-katex@^1 katexGive the chat an entry of its own, which imports the package’s script and adds each plugin to every
chat with onAparteSetup:
// resources/js/chat.js
import { onAparteSetup } from '../../vendor/apartejs/laravel/resources/js/aparte.js';
import 'katex/dist/katex.min.css';
import { setupKatex } from '@aparte/plugin-katex';
onAparteSetup((config) => setupKatex({}, config));Add it to the inputs in vite.config.js, so that the production build has it:
laravel({ input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'] }),Load it on the pages that show a chat, with @vite('resources/js/chat.js'). In a Filament panel:
use Filament\View\PanelsRenderHook;
use Illuminate\Support\Facades\Blade;
$panel->renderHook(PanelsRenderHook::HEAD_END, fn (): string => Blade::render("@vite('resources/js/chat.js')"));A separate entry keeps your app.js out of the panel: a second Alpine, or your app’s styles, would
break Filament’s.
onAparteSetupreceives each chat’s config, and the chat element as its second argument. What it returns, if a function, runs when the chat stops. Call it at the top level of that entry and keep it synchronous: a chat starts once the entry has run.- The import reads from
vendor/, so runcomposer installbeforenpm run build, in CI and Docker builds too. - If you install the package from a local path (a Composer path repository), Vite follows the link to
the package’s own folder: add
resolve: { dedupe: ['@aparte/core', '@aparte/runner-ag-ui', '@aparte/plugin-marked', 'marked'] }tovite.config.jsso that every import resolves from your app. - After changing
APARTE_ASSETSin production, runphp artisan config:cacheagain.
This needs apartejs/laravel 0.2 or later.
Good to know
Section titled “Good to know”- The look is set with aparté’s CSS variables. It follows the system’s light or
dark mode;
follow-dark-classmakes it follow Tailwind’sdarkclass instead. - What is kept: the SDK keeps the conversation in your database, tied to the signed-in user, and the model’s memory comes from there. What is on screen is kept for the tab: closing it starts a new conversation.
- A conversation is identified by an id the browser creates, or by
thread="..."on the component. - Guests can chat if your route allows them. Their conversations belong to nobody, so the id is all that protects one.
- In production, replies stream through nginx and PHP-FPM with no configuration: the SDK tells nginx
not to buffer them. Each reply in progress holds a PHP worker until it ends, so count them in
pm.max_children. - The SDK’s AG-UI stream, which this package reads, is not in its documentation yet, so a new
laravel/aiversion could change it.