Why I Built a Business Content Layer on Top of Laravel AI SDK
Source: Dev.to
The problem with raw LLM calls in business apps
Here’s what generating a payment reminder looks like with laravel/ai directly:
$ai = app(\Laravel\Ai\Contracts\Ai::class);
$response = $ai->text(
"You are a professional business assistant. Generate a payment reminder
email for a client. The invoice number is #1042. It is 30 days overdue.
The amount is 1500 EUR. The client name is Jean Martin.
Our company is Acme Corp.
Return a JSON object with: subject, message, call_to_action.
Do not invent any information not provided above.
Use a firm but professional tone.
Language: French."
);
// Now parse the response...
// Hope it returned valid JSON...
// Hope it didn't invent an amount...
// Hope the tone is right...It works… until it doesn’t.
Why this approach breaks down in production
- Hard‑coded context – prompts contain all the data, so there’s no reusable structure.
- Fragile output parsing – the model sometimes returns prose instead of JSON.
- Anti‑hallucination is only a suggestion – the model can still invent data.
- Tone, language, audience – must be specified manually for every call.
- No logging – you have no record of what was generated, when, or for which tenant.
All of this gets rewritten for every project and every feature.
What I wanted instead
A layer that handles the business concerns so I only describe what I want:
$response = BusinessAssistant::generate(new AssistantRequestData(
task: AssistantTask::Email,
preset: 'payment_reminder',
goal: 'Invoice #1042, 30 days overdue, second reminder',
language: 'fr',
tone: Tone::Direct,
context: [
'company' => true,
'customer' => ['id' => 42],
'billing' => ['invoice_id' => 99],
],
));
echo $response->subject; // "Rappel : Facture #1042 en attente de règlement"
echo $response->message; // Full professional email body
echo $response->call_to_action; // "Procéder au règlement"No prompt writing. No output parsing. No hallucination‑guard setup.
The architecture
The engine is a pipeline of contracts. Every step is an interface, so you can swap any layer without touching the rest.
AssistantRequestData
│
▼
RequestValidator — validates input fields
│
▼
PresetRepository — resolves the matching preset
│
▼
ContextResolver — calls your ContextProviders
│
▼
ContextSanitizer — strips sensitive keys, limits depth
│
▼
PromptBuilder — assembles system + user prompts
│
▼
TextGenerator — calls laravel/ai SDK
│
▼
OutputNormalizer — parses structured JSON response
│
▼
GenerationLogger — records to assistant_generations (best‑effort)
│
▼
AssistantResponseDataKey design decision: the engine never knows about your data model – it receives plain arrays, and you decide what goes in.
ContextProviders — the bridge between your DB and the engine
Instead of hard‑coding data into prompts, you write simple provider classes:
final class CustomerContextProvider implements ContextProvider
{
public function key(): string
{
return 'customer';
}
public function provide(AssistantRequestData $request, array $input = []): array
{
$customer = Customer::find($input['id']);
return [
'name' => $customer->full_name,
'email' => $customer->email,
'plan' => $customer->plan,
'since' => $customer->created_at->format('Y'),
];
}
}Register the provider once in a config file; every generation call automatically injects the right data.
The engine sanitises the result, stripping any sensitive keys before anything reaches the prompt.
Anti‑hallucination — enforced structurally
The biggest problem with AI in business apps is that the model inventes data.
Example: a payment reminder that invents an amount (€2,340 instead of the real €1,500).
Solution: make hallucination impossible by design. If the data isn’t present in the context, the model can’t use it. Three layers enforce this:
- ContextSanitizer – removes anything that shouldn’t be exposed.
- Preset system‑prompt constraints – limit what the model may output.
- OutputNormalizer validation – ensures the final JSON matches the expected schema.
14 presets — what’s included
| Preset | Description |
|---|---|
email | Professional business email |
reply | Contextual client reply |
payment_reminder | Overdue invoice reminder |
appointment_confirmation | Appointment details + confirmation |
support_reply | Empathetic support response |
follow_up | Post‑meeting follow‑up |
reminder | Generic reminder |
announcement | Company / product announcement |
promotion | Commercial promotional email |
social_post | Publishable social media post |
internal_note | Internal team memo |
summary | Document or interaction summary |
customer_onboarding | Welcome / onboarding communication |
feedback_request | Request for customer feedback |
(Feel free to add more presets as your product evolves.)
TL;DR
- Wrap raw
laravel/aicalls in a pipeline that handles validation, preset resolution, context injection, sanitisation, prompt building, generation, normalisation, and logging. - Use ContextProviders to pull data from your database in a reusable, testable way.
- Enforce anti‑hallucination by never exposing data that isn’t explicitly provided.
- Define presets for each common business‑type generation to keep prompts DRY and maintainable.
With this structure you stop rewriting the same scaffolding on every project and get a reliable, auditable AI‑powered assistant for all your business‑logic generation needs.
Overview
stomer_summary– Customer briefing for agentsrewrite– Text reformulation
Real examples
SaaS billing – second payment reminder
BusinessAssistant::generate(new AssistantRequestData(
task: AssistantTask::Email,
preset: 'payment_reminder',
goal: 'Second reminder. Invoice INV-2024-0112, 30 days overdue.',
tone: Tone::Direct,
language: 'fr',
context: [
'company' => true,
'customer' => ['id' => 14],
'billing' => ['invoice_id' => 5501],
],
));Driving school – lesson confirmation
BusinessAssistant::generate(new AssistantRequestData(
task: AssistantTask::Email,
preset: 'appointment_confirmation',
goal: 'Confirm driving lesson. Remind student to bring permit.',
tone: Tone::Friendly,
language: 'fr',
context: [
'company' => true,
'customer' => ['id' => 88],
'appointment' => ['lesson_id' => 334],
],
));CRM support – billing dispute
BusinessAssistant::generate(new AssistantRequestData(
task: AssistantTask::Reply,
preset: 'support_reply',
goal: 'Client was billed twice. Acknowledge, apologize, confirm investigation.',
tone: Tone::Reassuring,
language: 'en',
context: [
'company' => true,
'customer' => ['id' => 42],
'documents' => ['ticket_id' => 1091],
],
));Provider flexibility
Switch providers with a single .env change – no code modification required:
# Anthropic Claude
BUSINESS_ASSISTANT_PROVIDER=anthropic
BUSINESS_ASSISTANT_MODEL=claude-haiku-4-5-20251001
# OpenAI
BUSINESS_ASSISTANT_PROVIDER=openai
BUSINESS_ASSISTANT_MODEL=gpt-4o-mini
# Local Ollama
BUSINESS_ASSISTANT_PROVIDER=ollama
BUSINESS_ASSISTANT_MODEL=qwen2.5:3bMulti‑tenant support
Built inside a multi‑tenant ERP, so tenant scoping is first‑class:
$request = new AssistantRequestData(
task: AssistantTask::Email,
goal: '...',
userIdentifier: (string) auth()->id(),
tenantIdentifier: (string) $tenant->id,
);Every generation is logged to assistant_generations with tenant and user identifiers – ready for quota tracking and per‑tenant reporting.
Debug without calling the LLM
Preview the full prompt without making an API call:
$prompt = BusinessAssistant::preview($request);
echo $prompt->systemPrompt; // full system instructions
echo $prompt->userPrompt; // user prompt with injected contextInstallation
composer require fsdev/laravel-business-assistant
php artisan vendor:publish --tag=business-assistant-config
php artisan migrate
php artisan business-assistant:doctor
php artisan business-assistant:demoHonest about what this is
- Commercial package (not MIT).
- Lifetime release – Laravel 12,
laravel/ai~0.2.6. - One‑time purchase, no updates guaranteed.
- Not a replacement for
laravel/aior Prism – it sits on top and handles the business layer they deliberately don’t.
payhip.com/b/TkFob — €49 solo / €149 agency
Happy to answer questions about the architecture or the preset system in the comments.
Built by Fsdev — tematahotoa.tini@gmail.com