KI-Softwareentwicklung

KI-Support-Desk mit Laravel 13 und Blade bauen

Ein vollständiger KI-Support-Desk in Laravel 13: Ein Blade-Formular nimmt Tickets an, ein Agent klassifiziert sie per Structured Output, ein Queue-Job entwirft die Antwort, ein Mitarbeiter prüft sie in der Inbox und sendet.

Profi110 Min.Autor: Martin TomczakAktualisiert: 09.07.2026
Isometrisches Diagramm des KI-Support-Desk: Ticket, KI-Triage, Antwortentwurf und Mitarbeiter-Inbox als Kreislauf

Was Sie in diesem Tutorial bauen

Sie bauen in diesem Tutorial einen kompletten KI-Support-Desk mit Laravel 13 und Blade: eine kleine, aber vollständige App, die einen echten Prozess abbildet. Ein Kunde schickt über ein Formular ein Ticket. Ein KI-Agent klassifiziert es zuverlässig in eine von drei Kategorien. Ein zweiter Agent entwirft im Hintergrund eine Antwort. Ein Mitarbeiter sieht den Entwurf in einer Inbox, kennzeichnet als KI, prüft ihn und sendet. Der Mensch bleibt in der Entscheidung, die KI nimmt ihm die Fleißarbeit ab.

Das ist bewusst kein Schnipsel-Tutorial. Am Ende läuft eine App mit Datenbank, Formularvalidierung, einem Queue-Job und zwei Ansichten. Wenn Sie den Service Container und das AI SDK noch nicht kennen, arbeiten Sie zuerst das Grundlagen-Tutorial zu Laravel Service Container und AI SDK durch. Dort erkläre ich, warum ein KI-Agent ein ganz normaler Container-Service ist. Hier wenden wir das an.

Stand dieses Tutorials: Laravel 13.19, laravel/ai 0.9.0, PHP 8.5, Azure OpenAI mit dem Deployment gpt-5.5-1. Jeder Codeabschnitt ist real ausgeführt, die gezeigten Ausgaben stammen aus echten Läufen. Ein Hinweis vorweg: laravel/ai steht noch vor Version 1.0, pinnen Sie die Version und prüfen Sie Signaturen nach einem Update.

Der Support-Desk-Kreislauf vom Kundenticket über die KI-Triage bis zur geprüften Antwort in der Mitarbeiter-Inbox
Der Support-Desk-Kreislauf vom Kundenticket über die KI-Triage bis zur geprüften Antwort in der Mitarbeiter-Inbox

Schritt 1: Projekt, AI SDK und Azure vorbereiten

Sie brauchen PHP 8.3 oder neuer. Legen Sie das Projekt an, holen Sie das Laravel AI SDK und richten Sie Azure ein:

composer create-project "laravel/laravel:^13.0" support-desk
cd support-desk
composer require laravel/ai
php artisan vendor:publish --tag=ai-config

Tragen Sie die Azure-Zugangsdaten in die .env ein. Der azure-Treiber ist in laravel/ai eingebaut, in AZURE_OPENAI_URL steht nur die Ressourcen-Basis, den Pfad hängt der Treiber selbst an:

AI_DEFAULT_PROVIDER=azure

AZURE_OPENAI_API_KEY=ihr-schluessel
AZURE_OPENAI_URL=https://ihre-ressource.services.ai.azure.com
AZURE_OPENAI_API_VERSION=2026-04-24
AZURE_OPENAI_DEPLOYMENT=gpt-5.5-1

Setzen Sie in config/ai.php noch den Standard auf die Umgebungsvariable, sonst bleibt er fest auf openai:

// config/ai.php
'default' => env('AI_DEFAULT_PROVIDER', 'openai'),

Die Datenbank ist im frischen Projekt bereits SQLite, database/database.sqlite existiert. Für die Queue nutzen wir denselben einfachen Weg: In der .env steht QUEUE_CONNECTION=database, die passende Tabelle bringt Laravel mit. Kein zusätzlicher Dienst nötig.

Schritt 2: Das Datenmodell für Tickets

Ein Ticket hält die Kundendaten, die Nachricht, die von der KI gesetzte Kategorie, den KI-Entwurf und einen Status. Erzeugen Sie Migration und Modell:

php artisan make:model Ticket -m

Die Migration:

Schema::create('tickets', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email');
    $table->text('message');
    $table->string('category')->nullable();   // von der KI gesetzt
    $table->text('draft_reply')->nullable();   // KI-Antwortentwurf
    $table->string('status')->default('neu');  // neu, beantwortet
    $table->timestamps();
});

Das Modell braucht nur die ausfüllbaren Felder:

class Ticket extends Model
{
    protected $fillable = [
        'name', 'email', 'message', 'category', 'draft_reply', 'status',
    ];
}

Wandern Sie die Migration ein mit php artisan migrate. Die Kategorie und der Entwurf sind bewusst nullable: Sie entstehen erst durch die KI, das Ticket wird schon vorher gespeichert.

Schritt 3: Wie liefert die KI eine verlässliche Kategorie?

Über Structured Output. Ein normaler Prompt liefert Freitext, und Freitext ist unberechenbar: mal „Das ist eine Rechnungsfrage", mal „Kategorie: Abrechnung". Für eine Weiche in Ihrer App brauchen Sie genau einen von drei festen Werten. Das AI SDK erzwingt das über ein Schema. Der Agent implementiert HasStructuredOutput und beschreibt in schema() ein Feld als Enum:

<?php

namespace App\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;

#[Provider(Lab::Azure)]
#[Model('gpt-5.5-1')]
class TriageAgent implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): string
    {
        return 'Du klassifizierst Support-Nachrichten eines B2B-Softwarehauses. '
            .'Waehle genau eine Kategorie.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'category' => $schema->string()
                ->enum(['Rechnung', 'Technik', 'Sonstiges'])
                ->required(),
        ];
    }

    public function classify(string $message): string
    {
        return $this->prompt($message)['category'];
    }
}

Der Aufruf bleibt prompt(), aber die Antwort ist jetzt ein StructuredAgentResponse, den Sie wie ein Array lesen: $response['category']. Drei echte Läufe gegen Azure zeigen, dass das trägt:

"Ihre letzte Rechnung war zu hoch, bitte pruefen"  -> Rechnung
"Der Export-Button wirft einen 500er"              -> Technik
"Habt ihr auch ein Buero in Wien?"                 -> Sonstiges

Kein Parsen, kein Rätselraten. Das Modell darf nur einen der drei Werte zurückgeben.

Aus einer freien Kundennachricht wird per Schema eine von drei festen Kategorien
Aus einer freien Kundennachricht wird per Schema eine von drei festen Kategorien

Schritt 4: Der SupportAgent für den Antwortentwurf

Der zweite Agent formuliert einen Antwortvorschlag. Er ist ein gewöhnlicher Text-Agent, die instructions() geben den Ton vor:

#[Provider(Lab::Azure)]
#[Model('gpt-5.5-1')]
class SupportAgent implements Agent
{
    use Promptable;

    public function instructions(): string
    {
        return 'Du bist der Support-Assistent eines DACH-B2B-Softwarehauses. '
            .'Schreibe einen freundlichen, konkreten Antwortentwurf auf Deutsch, '
            .'per Sie, hoechstens vier Saetze. Keine erfundenen Zusagen.';
    }

    public function draftReplyFor(string $message): string
    {
        return trim($this->prompt('Kundennachricht: '.$message)->text);
    }
}

Die Anweisung „keine erfundenen Zusagen" ist kein Deko-Satz. Ein Support-Modell, das großzügig Rückerstattungen verspricht, macht echten Schaden. Der Entwurf geht ohnehin über den Tisch eines Mitarbeiters, aber die Leitplanke im Prompt senkt das Risiko.

Schritt 5: Das Ticket-Formular in Blade

Jetzt die erste Ansicht. Sie nutzen hier reines Blade mit einem kleinen eigenen Stylesheet, kein Node und kein Build-Schritt. Ein Layout unter resources/views/layout.blade.php mit einem @yield reicht:

<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="utf-8">
    <title>@yield('title', 'KI-Support-Desk')</title>
    <link rel="stylesheet" href="{{ asset('support.css') }}">
</head>
<body>
    <main class="wrap">
        @if (session('status'))
            <div class="flash">{{ session('status') }}</div>
        @endif
        @yield('content')
    </main>
</body>
</html>

Das Formular unter resources/views/tickets/create.blade.php zeigt die drei Felder und die Validierungsfehler. Das @error-Directive rendert die Meldung direkt am Feld:

@extends('layout')
@section('content')
    <h1>Wie koennen wir helfen?</h1>
    <form method="POST" action="{{ route('tickets.store') }}" class="card">
        @csrf
        <label>Name<input type="text" name="name" value="{{ old('name') }}" required></label>
        @error('name') <span class="err">{{ $message }}</span> @enderror

        <label>E-Mail<input type="email" name="email" value="{{ old('email') }}" required></label>
        @error('email') <span class="err">{{ $message }}</span> @enderror

        <label>Ihre Nachricht<textarea name="message" rows="6" required>{{ old('message') }}</textarea></label>
        @error('message') <span class="err">{{ $message }}</span> @enderror

        <button type="submit">Anliegen absenden</button>
    </form>
@endsection

Die Validierungsregeln gehören in einen FormRequest, nicht in den Controller. So bleibt der Controller schlank:

class StoreTicketRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name'    => ['required', 'string', 'max:120'],
            'email'   => ['required', 'email', 'max:190'],
            'message' => ['required', 'string', 'min:10', 'max:2000'],
        ];
    }
}

Schritt 6: Das Ticket annehmen und klassifizieren

Der Controller bindet alles zusammen. Er bekommt den TriageAgent per Type-Hint in die Methode injiziert, der Container baut ihn. Er klassifiziert, speichert und stößt die Entwurfserzeugung an:

public function store(StoreTicketRequest $request, TriageAgent $triage)
{
    $data = $request->validated();

    // 1. KI-Triage: verlaessliche Enum-Kategorie
    $data['category'] = $triage->classify($data['message']);

    // 2. Ticket speichern
    $ticket = Ticket::create($data);

    // 3. Antwortentwurf entkoppelt erzeugen
    GenerateDraftReplyJob::dispatch($ticket->id);

    return redirect()->route('tickets.create')
        ->with('status', 'Danke, Ihr Anliegen wurde als "'.$ticket->category.'" aufgenommen.');
}

Beachten Sie die Reihenfolge. Die Klassifikation läuft synchron, weil sie schnell ist und die Kategorie sofort am Ticket stehen soll. Die Antwort dagegen wandert in einen Job. Warum, klärt der nächste Schritt.

Schritt 7: Warum gehört die Antwort in einen Queue-Job?

Weil ein LLM-Aufruf mehrere Sekunden dauert, und niemand soll nach dem Absenden so lange auf eine weiße Seite starren. Die Antwortgenerierung läuft deshalb im Hintergrund. Der Job lädt das Ticket, ruft den SupportAgent und speichert den Entwurf:

class GenerateDraftReplyJob implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $ticketId) {}

    public function handle(SupportAgent $support): void
    {
        $ticket = Ticket::find($this->ticketId);

        if ($ticket === null) {
            return;
        }

        $ticket->update([
            'draft_reply' => $support->draftReplyFor($ticket->message),
        ]);
    }
}

Der SupportAgent kommt auch hier per Container in die handle()-Methode, kein new. Der Request endet in Millisekunden, der Worker erledigt den langsamen Teil. Starten Sie den Worker in einem zweiten Terminal:

php artisan queue:work

Der echte Lauf sieht so aus, ein Job braucht hier vier Sekunden:

2026-07-09 09:58:09 App\Jobs\GenerateDraftReplyJob .. RUNNING
2026-07-09 09:58:14 App\Jobs\GenerateDraftReplyJob .. 4s DONE

Diese vier Sekunden hätte sonst der Kunde im Browser gewartet. Vergessen Sie den Worker, bleibt der Entwurf leer und die Inbox zeigt „wird erstellt". Das ist kein Fehler, nur ein nicht laufender Worker.

Der Web-Request endet sofort, ein Worker erzeugt den KI-Entwurf im Hintergrund
Der Web-Request endet sofort, ein Worker erzeugt den KI-Entwurf im Hintergrund

Schritt 8: Die Mitarbeiter-Inbox mit KI-Kennzeichnung

Die zweite Ansicht listet die Tickets, gefiltert nach Kategorie, und zeigt den KI-Entwurf zum Bearbeiten. Der Controller lädt die Tickets:

public function inbox(Request $request)
{
    $filter = $request->query('kategorie');

    $tickets = Ticket::query()
        ->when($filter, fn ($q) => $q->where('category', $filter))
        ->latest()
        ->get();

    return view('inbox', ['tickets' => $tickets, 'filter' => $filter]);
}

Im Blade steht die entscheidende Zeile: der Entwurf trägt eine sichtbare KI-Kennzeichnung. Das ist keine Kosmetik, sondern Pflicht, dazu gleich mehr:

@if ($ticket->draft_reply)
    <form method="POST" action="{{ route('tickets.reply', $ticket) }}" class="draft">
        @csrf
        <p class="ai-note">Von KI vorgeschlagen. Bitte vor dem Senden pruefen.</p>
        <textarea name="reply" rows="4">{{ $ticket->draft_reply }}</textarea>
        <button type="submit" @disabled($ticket->status === 'beantwortet')>Antwort senden</button>
    </form>
@else
    <p class="pending">Antwortentwurf wird erstellt...</p>
@endif

Der Mitarbeiter kann den Text ändern und senden. Das Absenden setzt den Status und speichert die finale Fassung:

public function reply(Request $request, Ticket $ticket)
{
    $ticket->update([
        'draft_reply' => $request->input('reply', $ticket->draft_reply),
        'status'      => 'beantwortet',
    ]);

    return redirect()->route('inbox')->with('status', 'Antwort an '.$ticket->email.' gesendet.');
}

Ein echter Entwurf, den Azure OpenAI zu einer Rechnungsfrage erzeugt hat, klang so:

Vielen Dank fuer Ihren Hinweis zur Rechnung 2026-0815. Der
Kleinunternehmerstatus Ihres Unternehmens befreit in der Regel nicht von der
Umsatzsteuer auf Eingangsrechnungen. Wenn bei Ihnen ein anderer steuerlicher
Sachverhalt vorliegt, senden Sie uns bitte die Nachweise, dann pruefen wir die
Rechnung erneut.

Das ist brauchbar, aber nicht blind zu vertrauen. Genau dafür gibt es die Prüfung durch den Menschen.

Mitarbeiter-Inbox mit Kategorie-Badge, KI-Entwurf und sichtbarer KI-Kennzeichnung
Mitarbeiter-Inbox mit Kategorie-Badge, KI-Entwurf und sichtbarer KI-Kennzeichnung

Ein Wort zur Kennzeichnung. Sobald eine KI Inhalte für Menschen erzeugt, greift Artikel 50 des EU AI Act. Ab dem 2. August 2026 muss klar sein, dass ein Text von einer KI stammt. Im Support-Desk sieht der Mitarbeiter die Herkunft am Entwurf, und weil er vor dem Senden prüft und freigibt, trifft nicht die Maschine die Entscheidung, sondern ein Mensch. Diese zwei Eigenschaften, sichtbare Kennzeichnung und Mensch im Loop, sind der Unterschied zwischen einem Demo-Spielzeug und einer verantwortbaren Funktion.

Schritt 9: Den ganzen Ablauf testen

Der Reiz einer sauber verdrahteten App zeigt sich im Test. Sie faken beide Agenten und die Queue, dann läuft der komplette HTTP-Ablauf ohne einen einzigen Azure-Aufruf. Kein Netzwerk, keine Tokenkosten, deterministisch:

public function test_ticket_submission_classifies_and_queues_a_draft(): void
{
    Queue::fake();
    TriageAgent::fake([['category' => 'Technik']]);

    $response = $this->post('/tickets', [
        'name'    => 'Max Muster',
        'email'   => 'max@beispiel.de',
        'message' => 'Der Export-Button liefert einen 500er Fehler.',
    ]);

    $response->assertRedirect(route('tickets.create'));
    $this->assertDatabaseHas('tickets', [
        'email' => 'max@beispiel.de', 'category' => 'Technik', 'status' => 'neu',
    ]);
    Queue::assertPushed(GenerateDraftReplyJob::class);
    TriageAgent::assertPrompted(fn ($prompt) => $prompt->contains('Export-Button'));
}

Zwei Feinheiten, die mich beim Schreiben Zeit gekostet haben. Erstens: Ein strukturierter Agent wird mit einem Array gefaked, nicht mit einem String. TriageAgent::fake([['category' => 'Technik']]) erzeugt eine strukturierte Antwort, ein einfacher String dagegen eine Textantwort, und dann bricht der Array-Zugriff ['category']. Zweitens: assertPrompted('Text') prüft auf exakte Gleichheit. Für einen Teiltreffer nehmen Sie eine Closure mit $prompt->contains('Text').

Der Job und das Senden bekommen je einen eigenen Test, hier mit gefaktem SupportAgent:

public function test_job_writes_the_ai_draft(): void
{
    SupportAgent::fake(['Wir schauen uns den Export-Fehler umgehend an.']);

    $ticket = Ticket::create([
        'name' => 'Max', 'email' => 'max@beispiel.de',
        'message' => 'Export kaputt', 'category' => 'Technik',
    ]);

    (new GenerateDraftReplyJob($ticket->id))->handle(app(SupportAgent::class));

    $this->assertSame('Wir schauen uns den Export-Fehler umgehend an.', $ticket->fresh()->draft_reply);
}

Alle vier Tests laufen bei mir grün, in zusammen 130 Millisekunden. Eine Testsuite, die bei jedem Lauf echte KI-Aufrufe macht, würde niemand oft ausführen. Weil die Agenten hinter dem Container liegen, tauschen die Fakes sie mühelos aus.

Schritt 10: Stolperfallen aus der Praxis

Vier Dinge, die beim echten Bauen bissen.

Der Import beim Schema. Die Methode schema() verlangt Illuminate\Contracts\JsonSchema\JsonSchema, also das Contract-Interface, nicht die gleichnamige konkrete Klasse Illuminate\JsonSchema\JsonSchema. Importieren Sie die falsche, gibt es einen harten Fehler beim Aufruf. Das ist tückisch, weil beide gleich heißen.

Der Deployment-Name als Modell. #[Model('gpt-5.5-1')] muss exakt Ihr Azure-Deployment treffen, nicht eine generische Kennung wie gpt-4o. Bei Azure ist der Modellname der Deployment-Name.

Der vergessene Worker. Ohne laufenden php artisan queue:work bleibt draft_reply leer, und die Inbox zeigt dauerhaft „wird erstellt". In Produktion gehört der Worker unter einen Prozessmanager wie Supervisor, damit er nach einem Absturz neu startet.

Der stille Standard-Provider. Die veröffentlichte config/ai.php setzt default fest auf openai. Ohne die env-Zeile aus Schritt 1 oder ohne das #[Provider]-Attribut am Agenten läuft Ihr Aufruf gegen den falschen Anbieter. Das Attribut ist die verlässliche, explizite Route.

Was Sie jetzt haben

Sie haben einen lauffähigen KI-Support-Desk: ein Blade-Formular, eine verlässliche KI-Triage per Structured Output, einen entkoppelten Queue-Job für den Antwortentwurf, eine Inbox mit KI-Kennzeichnung und einen Menschen, der prüft und sendet. Vier Tests decken den Ablauf ab, ohne die KI wirklich zu rufen. Das ist ein echter Prozess, kein Schnipsel.

Von hier aus lohnen sich drei Ausbauten. Geben Sie dem SupportAgent Tools, damit er den Bestellstatus oder offene Rechnungen des Kunden nachschlägt, bevor er entwirft. Schützen Sie die Inbox mit Laravels Authentifizierung, denn sie zeigt Kundendaten. Und protokollieren Sie, wer welchen Entwurf geändert und gesendet hat. Wenn Sie so etwas in Ihr Produkt bringen wollen, unterstütze ich Sie über die KI-Softwareentwicklung. Wie KI-Funktionen sauber in gewachsene Systeme einziehen, vertieft der Beitrag zur KI-Integration in bestehende Systeme. Und wenn Ihr Stack auf Laravel läuft, hilft der Überblick im Angular-und-Laravel-Themengebiet.

FAQ

Häufige Fragen

Warum Structured Output statt eines normalen Prompts für die Kategorie?

Weil ein normaler Prompt Freitext liefert, den Sie parsen müssten, und weil das Modell dann leicht Varianten erfindet. Mit einem Enum-Schema gibt das AI SDK dem Modell genau die erlaubten Werte vor. Sie bekommen zuverlässig einen von drei Strings zurück und können damit ohne Zusatzlogik eine Weiche in der App stellen.

Muss die Antwortgenerierung wirklich in einen Job?

Für eine ernsthafte App ja. Ein LLM-Aufruf dauert Sekunden, im Beispiel vier. Diese Zeit im Web-Request zu blockieren wäre eine schlechte Nutzererfahrung und bindet einen PHP-Prozess. Der Queue-Job entkoppelt das. Für einen schnellen Prototyp ohne Worker könnten Sie synchron generieren, aber in Produktion gehört der langsame Teil in den Hintergrund.

Wie erfülle ich die KI-Kennzeichnungspflicht aus dem EU AI Act?

Machen Sie sichtbar, dass ein Text von einer KI stammt, und halten Sie einen Menschen in der Entscheidung. Im Support-Desk trägt der Entwurf den Hinweis „Von KI vorgeschlagen", und der Mitarbeiter gibt ihn frei. Artikel 50 verlangt die Transparenz ab dem 2. August 2026. Das ist eine technische Umsetzung, keine Rechtsberatung, bei heiklen Einsätzen lassen Sie die Ausgestaltung juristisch prüfen.

Kann ich die App ohne Azure OpenAI bauen?

Ja. Das AI SDK bringt Treiber unter anderem für OpenAI, Anthropic, Gemini und Bedrock mit. Sie ändern nur das #[Provider]-Attribut an den beiden Agenten und hinterlegen den passenden Schlüssel in config/ai.php. Der übrige Code, Migration, Controller, Job, Blade, bleibt unverändert.

Nächster Schritt

KI-Funktion in Ihrem Laravel-Produkt?

Sie wollen so eine KI-Funktion in Ihrem Laravel-Produkt, sauber verdrahtet und testbar? Ich unterstütze bei Architektur und Umsetzung.

Erstgespräch anfragen