stejac / guarded-chat-bundle
Guardrails for public AI chats in Symfony apps: prompt versioning, injection prefilter, kill switch, budget and rate limits, signed sessions, retention. Built on Symfony AI.
Package info
github.com/Stefan39/GuardedChatBundle
Type:symfony-bundle
pkg:composer/stejac/guarded-chat-bundle
Fund package maintenance!
Requires
- php: >=8.3
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/orm: ^3.3
- psr/clock: ^1.0
- psr/log: ^3.0
- symfony/ai-agent: ^0.14
- symfony/ai-bundle: ^0.14
- symfony/ai-generic-platform: ^0.14
- symfony/ai-platform: ^0.14
- symfony/cache-contracts: ^3.5
- symfony/clock: ^7.4 || ^8.0
- symfony/config: ^7.4 || ^8.0
- symfony/console: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/doctrine-bridge: ^7.4 || ^8.0
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/http-client: ^7.4 || ^8.0
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/messenger: ^7.4 || ^8.0
- symfony/rate-limiter: ^7.4 || ^8.0
- symfony/routing: ^7.4 || ^8.0
- symfony/translation: ^7.4 || ^8.0
- symfony/translation-contracts: ^3.5
- symfony/uid: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Requires (Dev)
- dragonmantank/cron-expression: ^3.4
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpunit/phpunit: ^12.0
- symfony/asset-mapper: ^7.4 || ^8.0
- symfony/browser-kit: ^7.4 || ^8.0
- symfony/cache: ^7.4 || ^8.0
- symfony/dom-crawler: ^7.4 || ^8.0
- symfony/mime: ^7.4 || ^8.0
- symfony/scheduler: ^7.4 || ^8.0
- symfony/twig-bundle: ^7.4 || ^8.0
Suggests
- dragonmantank/cron-expression: Needed by symfony/scheduler for cron expressions
- symfony/asset-mapper: Serves the widget's JavaScript and CSS
- symfony/scheduler: To prune expired chat sessions on a recurring schedule (guarded_chat.retention.schedule)
- symfony/stimulus-bundle: Runs the widget's Stimulus controller
- symfony/twig-bundle: Renders @GuardedChat/widget.html.twig
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 11:44:29 UTC
README
Guardrails for public AI chats in Symfony apps: signed sessions, prompt versioning, injection prefilter, kill switch, rate limits, budget stop, retention. Built on Symfony AI, with a drop-in chat widget.
Why not just use symfony/ai?
Symfony AI talks to the model: platforms, agents, tools, streaming. It deliberately leaves open everything around a public, anonymous chat endpoint, and that is the part that hurts when a site goes live:
| Problem on a public chat | Symfony AI alone | This bundle |
|---|---|---|
| Visitors paste "ignore previous instructions" | up to you | prefilter as an input processor, the model is never called |
| A bot or a bored visitor drains your token budget | up to you | per-client rate limits, session message cap, budget stop |
| Who is allowed to continue a conversation? | up to you | signed session tokens, lifetime and idle timeout |
| Fix the wording of the system prompt without a deploy | code constant | versioned prompts in the database, one active at a time |
| Switch the chat off during an incident | up to you | two-stage kill switch (config flag plus runtime switch) |
| Privacy: how long do you keep transcripts? | up to you | retention with a scheduled prune |
| Telling visitors they talk to an AI (Art. 50 EU AI Act) | up to you | disclosure in the widget and an enforced prompt instruction |
The bundle uses Symfony AI as its foundation, not as a competitor: exactly one internal class touches it (Internal\AiStreamAdapter), so a 0.x change there does not ripple through the rest.
Quick start
composer require stejac/guarded-chat-bundle
# config/packages/guarded_chat.yaml guarded_chat: platform: ai.platform.generic.litellm # any Symfony AI platform service model: my-model prompt: default: 'You are the assistant of Acme Bikes. Answer only questions about bikes and our shop.'
# config/routes/guarded_chat.yaml guarded_chat: resource: '@GuardedChatBundle/config/routes.php' prefix: /chat
{# templates/base.html.twig #} {{ include('@GuardedChat/widget.html.twig') }}
That is the whole integration: the widget needs AssetMapper with Symfony UX StimulusBundle (composer require symfony/asset-mapper symfony/stimulus-bundle); Flex registers its controller automatically. The bundle registers its Doctrine mapping but ships no migrations: run bin/console doctrine:migrations:diff and review the result.
With Symfony Flex the recipe adds the config files and an GUARDED_CHAT_ENABLED variable for you. For the platform itself see the Symfony AI documentation, for example the generic bridge for an OpenAI-compatible gateway such as LiteLLM:
# config/packages/ai.yaml ai: platform: generic: litellm: base_url: '%env(LITELLM_URL)%' api_key: '%env(LITELLM_KEY)%' completions_path: /chat/completions
Features
- Signed session tokens and per-session limits (lifetime, idle timeout, message count, history window)
- Versioned system prompts in the database, with a configured fallback and instructions that hold for every version (AI disclosure, form of address, output format)
- Prefilter for obvious prompt-injection phrases, as a Symfony AI input processor
- Two-stage kill switch: a hard
enabledflag plus a runtime switch you can flip without a deploy - Rate limits per client (session start, messages per minute, messages per day) and a budget stop that recognises an exhausted gateway budget
- Human verification hook plus a honeypot field
- Knowledge base with pluggable retrieval (PostgreSQL full text out of the box)
- Retention: sessions and their messages are deleted after a configurable number of days
- Streaming answers as server-sent events, with configurable action markers (e.g.
[[contact]]becomes anactionevent and a button) - Widget: a Stimulus controller plus an overridable Twig template, no CSS framework required
The widget
@GuardedChat/widget.html.twig renders a launcher button and a chat panel. Texts come in English and German from the guarded_chat translation domain, including the AI notice in the panel header and the greeting that discloses the AI to the visitor.
{{ include('@GuardedChat/widget.html.twig', {
actions: [{type: 'contact', label: 'Contact us', url: path('contact')}],
notice: 'Answers are generated by an AI and may contain errors.',
}) }}
| Variable | Meaning |
|---|---|
locale |
Locale sent with every request, default: request locale |
session_url, message_url |
Endpoints, default: the bundle's routes |
greeting |
First assistant message (also the AI disclosure), '' hides it |
notice |
Text under the panel title |
actions |
{type, label, url} entries; a button is shown under answers that carry that action |
verification_token |
Static token for the human verification hook |
max_message_length |
Input maxlength, default 500 |
Styling. The default look is a small stylesheet driven by CSS custom properties; Flex loads it, otherwise add {{ asset('@stejac/guarded-chat-bundle/dist/widget.css') }}:
.guarded-chat { --guarded-chat-accent: #0f766e; --guarded-chat-radius: 0.25rem; }
Your own markup. Extend the template and override a block (launcher, header, notice, messages, actions, form), or copy it to templates/bundles/GuardedChatBundle/widget.html.twig. The controller relies only on the data-…-target and data-action attributes, so Tailwind classes or any other design work. The bubble classes are values on the controller (user-class, assistant-class).
Human verification. The widget dispatches a verify event before it starts a session. A listener hands over a token, which is sent as verificationToken to POST /session and checked by your HumanVerifierInterface. Example with Cloudflare Turnstile (the bundle has no dependency on it):
document.addEventListener('stejac--guarded-chat-bundle--widget:verify', (event) => { event.detail.provide(new Promise((resolve) => turnstile.render('#captcha', { sitekey: '…', callback: resolve, }))); });
Configuration
guarded_chat: enabled: '%env(bool:GUARDED_CHAT_ENABLED)%' # stage 1 of the kill switch platform: ai.platform.generic.litellm model: my-model max_output_tokens: 300 prompt: default: '…' # fallback until a prompt version is stored pinned_instructions: - 'Answer in plain text without Markdown.' actions: contact: marker: '[[contact]]' instruction: 'When the visitor should get in touch, output [[contact]] as the final token.' session: { lifetime: 7200, idle_timeout: 1800, max_messages: 20, max_history: 6 } rate_limits: session: { limit: 5, interval: '1 hour' } message: { limit: 8, interval: '1 minute' } message_daily: { limit: 50, interval: '1 day' } retention: { days: 90 }
Endpoints
| Route | Purpose |
|---|---|
POST /chat/session |
Starts a session. Body: {"verificationToken": "", "website": "", "locale": "en"} (website is the honeypot and must stay empty). |
POST /chat/message |
Sends {"message": "...", "locale": "en"} and streams the answer as text/event-stream with text and action events. |
Errors are JSON with a stable error code and a translated message: rate_limited, message_limit_reached, budget_exhausted, gateway_unavailable, verification_failed, invalid_token, session_expired, invalid_request.
Extension points
Each is a service you point the matching configuration key at; the defaults are neutral.
| Key | Interface | Default |
|---|---|---|
runtime_switch |
ChatRuntimeSwitchInterface |
always on |
human_verifier |
HumanVerifierInterface |
lets everyone through |
client_identifier_resolver |
ClientIdentifierResolverInterface |
Request::getClientIp() |
budget_exhaustion_detector |
BudgetExhaustionDetectorInterface |
HTTP 429 means budget exhausted |
knowledge.retriever |
KnowledgeRetrieverInterface |
none (NullRetriever) |
Knowledge base
Answers can draw on hand-curated snippets (chat_knowledge_snippet: title, optional typical question, content, priority, source URL). Snippets created as admin are active at once; snippets suggested as api stay dormant until a person activates them, so a visitor cannot steer what the assistant knows.
guarded_chat: knowledge: retriever: postgres_full_text # default: none (the bundle then runs on any database) text_search_config: english # PostgreSQL text search configuration, default "german" max_snippets: 4 # per answer; bounds the prompt size subject: 'Acme Corp' # "Verified information about Acme Corp." (optional)
postgres_full_text needs PostgreSQL. For any other store, implement KnowledgeRetrieverInterface (retrieve(string $question, int $limit): list<KnowledgeSnippetInterface>) and put its service id into retriever. The ids of the snippets that fed an answer are stored with it in chat_message.context_snippet_ids. The header wording is translatable in the guarded_chat domain (knowledge.*).
Retention
bin/console guarded-chat:prune deletes sessions older than retention.days (default 90). With symfony/scheduler and dragonmantank/cron-expression installed, retention.schedule.enabled: true registers a recurring prune.
Requirements
PHP 8.3+, Symfony 7.4 or 8, Doctrine ORM 3, symfony/ai-bundle 0.14 (installed with the bundle). The widget additionally needs symfony/twig-bundle, symfony/asset-mapper and symfony/stimulus-bundle.
Development
composer test # PHPUnit composer phpstan # PHPStan, max level composer cs # php-cs-fixer (dry run) node --test "tests/js/*.test.mjs" # widget helpers
The PostgreSQL tests need GUARDED_CHAT_TEST_DATABASE_URL, see CONTRIBUTING.md.
License
MIT
