Laravel: AI-Drafted Support Replies with Human Approval Flow
A contact inbox becomes much more useful when it can propose a thoughtful reply, but automatically sending generated text is the wrong default. Names can be misspelled, promises can exceed policy, and an apparently simple question may carry context the model cannot see. The safer pattern is a draft pipeline: AI does the first-pass writing, while an authenticated person reviews, edits, and approves every response.
This tutorial builds that pipeline in Laravel on PHP 8.3 or later. A queued job calls the Smart Routing AI Model through Laravel’s built-in HTTP client, maps the response into explicit domain states, and stores only a draft. Approval remains a separate human action.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Smart Routing AI Model service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token shown there.
This service requires a token. Regenerating it revokes the previously active token, so coordinate rotation with deployment: update the application secret and restart workers promptly. Never commit the token or place it in logs, fixtures, screenshots, or client-side JavaScript.
The exact API operation is POST https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions, authenticated with Authorization: Bearer {serviceToken}. It accepts an OpenAI-compatible chat request and returns a standard OpenAI-style response. The service performs plan-based model routing and quota tracking.
Run one minimal request before involving Laravel. Replace both placeholders. Because the contract does not prescribe a literal model identifier, obtain the routing model identifier from the documentation for your activated plan instead of guessing one.
curl --request POST \
--url https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"model": "YOUR_PLAN_MODEL",
"messages": [
{
"role": "user",
"content": "Draft a brief reply confirming that we received the enquiry."
}
]
}'
A successful standard response contains generated text at choices[0].message.content. Production code must still treat that path as untrusted: a successful HTTP status does not guarantee a complete or correctly shaped body.
Store the credential and selected routing model in the deployment environment. In local development, use Laravel’s uncommitted .env file:
MIHAJLO_AI_TOKEN=YOUR_SERVICE_TOKEN
MIHAJLO_AI_MODEL=YOUR_PLAN_MODEL
QUEUE_CONNECTION=database
Expose those values through config/services.php. Reading env() only from configuration files keeps the application compatible with config:cache.
'mihajlo_ai' => [
'token' => env('MIHAJLO_AI_TOKEN'),
'model' => env('MIHAJLO_AI_MODEL'),
'endpoint' => 'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions',
],
Architecture: asynchronous drafting, synchronous approval
The browser should not wait on an external model call. Creating a draft therefore dispatches a queue job and immediately returns an accepted response. The job calls the API, validates the boundary response, and moves the message to either draft_ready or draft_failed. Approval is a separate authenticated request that supplies the reviewer’s final edited text.
The relevant project structure is deliberately small:
app/
Data/AiDraftResult.php
Http/Controllers/InboxDraftController.php
Jobs/GenerateReplyDraft.php
Models/ContactMessage.php
Services/SmartRoutingClient.php
config/services.php
database/migrations/..._create_contact_messages_table.php
routes/web.php
tests/Feature/InboxDraftControllerTest.php
tests/Unit/SmartRoutingClientTest.php
A queue adds operational responsibility, but it prevents slow upstream responses from consuming web workers and gives operators a clear place to inspect failures. The HTTP client owns short, bounded transport retries; the Laravel job itself does not repeatedly rerun an ambiguous request.
Create the inbox state model
Create the model, migration, job, and controller with Laravel’s generators. Ensure the database queue tables are also present using the queue-table generator provided by your Laravel version, then run the migrations.
php artisan make:model ContactMessage -m
php artisan make:job GenerateReplyDraft
php artisan make:controller InboxDraftController
php artisan migrate
The contact-message migration records both the machine draft and the human-approved version. Keeping them separate preserves review history and prevents a generated draft from masquerading as approved content.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('contact_messages', function (Blueprint $table): void {
$table->id();
$table->string('email');
$table->text('body');
$table->string('status')->default('pending');
$table->text('draft_reply')->nullable();
$table->text('final_reply')->nullable();
$table->string('ai_failure_code')->nullable();
$table->timestamp('approved_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('contact_messages');
}
};
Make those fields assignable in ContactMessage, and cast approved_at to datetime. In a larger inbox, replace free-form status strings with a backed PHP enum and add organization ownership columns.
Build a defensive API boundary
A small result object prevents controllers and jobs from understanding upstream JSON. It represents either usable content or a structured failure code.
<?php
namespace App\Data;
final readonly class AiDraftResult
{
private function __construct(
public bool $succeeded,
public ?string $content,
public ?string $failureCode,
) {}
public static function success(string $content): self
{
return new self(true, $content, null);
}
public static function failure(string $code): self
{
return new self(false, null, $code);
}
}
The client uses bounded connection and total timeouts. It retries only connection failures, HTTP 429 responses, and server errors, with a maximum of three attempts. Authentication and validation failures are deterministic and are never blindly retried.
<?php
namespace App\Services;
use App\Data\AiDraftResult;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
final class SmartRoutingClient
{
public function draft(string $customerMessage): AiDraftResult
{
$token = config('services.mihajlo_ai.token');
$model = config('services.mihajlo_ai.model');
$endpoint = config('services.mihajlo_ai.endpoint');
if (!is_string($token) || $token === '' ||
!is_string($model) || $model === '') {
return AiDraftResult::failure('configuration_error');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->acceptJson()
->connectTimeout(3)
->timeout(20)
->post($endpoint, [
'model' => $model,
'messages' => [
[
'role' => 'system',
'content' => 'Draft a concise, courteous support reply. Do not promise refunds, deadlines, or actions not stated by the business. Return only the proposed reply.',
],
[
'role' => 'user',
'content' => $customerMessage,
],
],
]);
} catch (ConnectionException) {
if ($attempt === 3) {
return AiDraftResult::failure('transport_error');
}
usleep(200000 * $attempt);
continue;
}
if ($response->successful()) {
$content = $response->json('choices.0.message.content');
if (!is_string($content) || trim($content) === '') {
return AiDraftResult::failure('invalid_response');
}
return AiDraftResult::success(trim($content));
}
if (in_array($response->status(), [401, 403], true)) {
return AiDraftResult::failure('authentication_error');
}
if (in_array($response->status(), [400, 422], true)) {
return AiDraftResult::failure('request_rejected');
}
$retryable = $response->status() === 429 ||
$response->serverError();
if (!$retryable) {
return AiDraftResult::failure('upstream_error');
}
if ($attempt < 3) {
sleep($attempt);
continue;
}
return AiDraftResult::failure(
$response->status() === 429
? 'quota_or_rate_limited'
: 'provider_unavailable'
);
}
return AiDraftResult::failure('upstream_error');
}
}
The prompt deliberately limits authority, but prompts are not security controls. Human review is the control. The client also avoids sending the contact’s email address; only the message body crosses the API boundary.
Generate drafts in a queue job
The job checks the current status before calling the service, which makes stale duplicate jobs harmless. It logs identifiers and failure categories, never message bodies, generated text, or credentials.
<?php
namespace App\Jobs;
use App\Models\ContactMessage;
use App\Services\SmartRoutingClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class GenerateReplyDraft implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public readonly int $messageId) {}
public function handle(SmartRoutingClient $client): void
{
$message = ContactMessage::find($this->messageId);
if (!$message || $message->status !== 'drafting') {
return;
}
$result = $client->draft($message->body);
if (!$result->succeeded) {
$message->update([
'status' => 'draft_failed',
'ai_failure_code' => $result->failureCode,
]);
Log::warning('ai_draft_failed', [
'message_id' => $message->id,
'failure_code' => $result->failureCode,
]);
return;
}
$message->update([
'status' => 'draft_ready',
'draft_reply' => $result->content,
'ai_failure_code' => null,
]);
Log::info('ai_draft_ready', [
'message_id' => $message->id,
]);
}
}
Keep approval explicitly human
The controller uses a transaction and row lock to prevent two draft requests from racing. It accepts only pending or failed messages for generation. The approval action requires fresh reviewer-supplied text rather than silently copying the draft.
<?php
namespace App\Http\Controllers;
use App\Jobs\GenerateReplyDraft;
use App\Models\ContactMessage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class InboxDraftController extends Controller
{
public function generate(ContactMessage $message): RedirectResponse
{
DB::transaction(function () use ($message): void {
$locked = ContactMessage::query()
->lockForUpdate()
->findOrFail($message->id);
abort_unless(
in_array($locked->status, ['pending', 'draft_failed'], true),
409
);
$locked->update([
'status' => 'drafting',
'ai_failure_code' => null,
]);
GenerateReplyDraft::dispatch($locked->id)->afterCommit();
});
return back()->with('status', 'Draft generation started.');
}
public function approve(
Request $request,
ContactMessage $message
): RedirectResponse {
abort_unless($message->status === 'draft_ready', 409);
$validated = $request->validate([
'reply' => ['required', 'string', 'max:10000'],
]);
$message->update([
'final_reply' => $validated['reply'],
'status' => 'approved',
'approved_at' => now(),
]);
return back()->with('status', 'Reply approved.');
}
}
Protect both routes with Laravel authentication. In a multi-tenant application, add a policy or scoped route binding so users can access only their organization’s messages.
use App\Http\Controllers\InboxDraftController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function (): void {
Route::post('/inbox/messages/{message}/drafts',
[InboxDraftController::class, 'generate']);
Route::patch('/inbox/messages/{message}/approval',
[InboxDraftController::class, 'approve']);
});
An approved record is still not automatically sent. Connect delivery to a separately authorized mail action if the inbox needs it. That separation makes “approve” auditable and avoids turning a drafting tutorial into an accidental autonomous sender.
Test success, retries, and human control
Laravel’s Http::fake() provides a deterministic transport. Prevent stray requests so a typo cannot call the live service during the test suite.
<?php
namespace Tests\Unit;
use App\Services\SmartRoutingClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class SmartRoutingClientTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('services.mihajlo_ai', [
'token' => 'test-token',
'model' => 'test-routing-model',
'endpoint' => 'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions',
]);
Http::preventStrayRequests();
}
public function test_it_maps_a_valid_draft(): void
{
Http::fake([
'*' => Http::response([
'choices' => [[
'message' => ['content' => 'Thanks for contacting us.'],
]],
], 200),
]);
$result = app(SmartRoutingClient::class)->draft('Are you open?');
$this->assertTrue($result->succeeded);
$this->assertSame('Thanks for contacting us.', $result->content);
}
public function test_it_does_not_retry_authentication_failure(): void
{
Http::fake(['*' => Http::response([], 401)]);
$result = app(SmartRoutingClient::class)->draft('Hello');
$this->assertFalse($result->succeeded);
$this->assertSame('authentication_error', $result->failureCode);
Http::assertSentCount(1);
}
}
Feature tests should additionally fake the queue, assert that generation moves a message to drafting, and assert that one job is dispatched. For approval, create a draft_ready message, authenticate a user, submit edited text, and verify final_reply, approved_at, and approved. Also test that pending and failed drafts cannot be approved.
Security, observability, and deployment
- Secrets: inject the token through the hosting platform’s secret store. After rotation, rebuild configuration caches and restart long-running workers.
- Data minimization: send only text required for drafting. Define retention and disclosure rules appropriate to the contact form’s privacy commitments.
- Prompt injection: treat customer text as hostile input. Generated content receives no tool access and no authority to send mail or change records.
- Output handling: escape draft and final text in Blade with
{{ }}. Do not render generated text through unescaped HTML directives. - Metrics: count successes and failure codes, record latency around the client call, and alert on sustained authentication, quota, transport, or malformed-response failures.
- Worker health: run a supervised queue worker, give it a timeout longer than the client’s bounded request window, and restart it during deployments.
Deploy the application code, provide MIHAJLO_AI_TOKEN and MIHAJLO_AI_MODEL, run php artisan migrate --force, rebuild configuration with php artisan config:cache, and restart queue workers with php artisan queue:restart. Confirm that the production queue connection is asynchronous and that a process supervisor keeps workers alive.
Common failure patterns
authentication_errorusually means the token is missing, malformed, revoked, or stale in a cached configuration or worker process.quota_or_rate_limitedrequires checking plan usage and request volume. More retries can make saturation worse.request_rejectedindicates that the submitted JSON or configured model identifier does not match the documented contract.invalid_responsemeans HTTP succeeded but the expected content path was absent or empty. Preserve the failure category and inspect sanitized upstream metadata.- A message stuck in
draftingusually points to an unavailable worker or a terminated job. Add an operational reconciliation command if interrupted jobs are common.
Final verification checklist
- The live token exists only in environment-backed secret configuration.
- The minimal API request succeeds with the activated plan’s documented model identifier.
- An authenticated draft request returns promptly and queues exactly one job.
- Successful output is stored only in
draft_replywith statusdraft_ready. - Authentication, validation, quota, transport, server, and malformed-response failures remain distinguishable.
- Logs contain message IDs and failure codes, but no customer text, reply text, or token.
- A person can edit the draft, and only that submitted text becomes
final_reply. - No route in this workflow sends a reply automatically.
The most important design choice is not the prompt or even the model router. It is the boundary between suggestion and authority. Let the model remove the blank-page burden, let the queue absorb unreliable network time, and let a person own the final words. That modest separation turns an impressive demo into a support feature a small business can responsibly operate.