Native PHP 8.3: AI Draft Replies for Inbox, Keeping You in Control
An AI reply becomes useful only when it behaves like a careful assistant, not an autonomous sender. For a small business inbox, the safest boundary is simple: the model proposes text, the application stores it separately, and a staff member reviews, edits, and sends it through the existing workflow.
This tutorial builds that boundary in Native PHP 8.3. The integration uses the Smart Routing AI Model’s OpenAI-compatible endpoint, native cURL, SQLite for a compact example inbox, defensive response mapping, bounded retries, PHPUnit tests, and explicit failure states. No generated text is sent automatically.
Get access before writing integration code
Start by registering an account, or use the sign-in page if you already have one.
- Open the Smart Routing AI Model service page.
- Choose the 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.
- Also use the documentation to confirm the model identifier supported by your activated plan.
This service requires a token. Authentication uses Authorization: Bearer {serviceToken}. Regenerating the service token revokes the previously active token, so token rotation must update every deployed instance that calls the service.
The exact request is POST https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions. Before building the feature, make one minimal request. Replace both placeholders locally; never commit the resulting command with a real credential.
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_DOCUMENTED_MODEL",
"messages": [
{"role": "user", "content": "Draft a polite acknowledgement of a contact request."}
]
}'
A successful response follows the standard OpenAI-style JSON shape. The implementation will defensively read choices[0].message.content rather than assuming every successful-looking body is usable.
Prepare the Native PHP project
You need PHP 8.3 or later with cURL, PDO SQLite, JSON, Composer, and a writable application data directory. The only runtime package is vlucas/phpdotenv in the compatible ^5.6 range. PHPUnit ^11.0 supplies the test runner.
mkdir inbox-drafts
cd inbox-drafts
composer init --name=example/inbox-drafts --no-interaction
composer require vlucas/phpdotenv:^5.6
composer require --dev phpunit/phpunit:^11.0
mkdir -p src public var tests
composer config autoload.psr-4.App\\ src/
composer dump-autoload
Create .env for local development and exclude it from version control. Production should inject the same variables through the process manager or secret store instead of copying a developer’s file onto the server.
SMART_ROUTING_TOKEN=YOUR_SERVICE_TOKEN
SMART_ROUTING_MODEL=YOUR_DOCUMENTED_MODEL
APP_DB_PATH=var/inbox.sqlite
APP_ENV=development
The project has four boundaries: the controller authenticates the staff request, the repository owns inbox data, the application service coordinates draft creation, and the API client alone understands the remote HTTP contract. This costs a few small classes, but it prevents cURL details, model output, and database updates from leaking into one fragile script.
inbox-drafts/
public/draft.php
src/AI/SmartRoutingClient.php
src/Inbox/InboxRepository.php
src/Inbox/DraftReplyService.php
tests/SmartRoutingClientTest.php
var/inbox.sqlite
.env
composer.json
Create inbox storage with a review boundary
The important column is draft_status. Generated content enters pending_review; it never enters a sent state. A production inbox can later attach its existing review and send interface to these fields.
CREATE TABLE contact_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_name TEXT NOT NULL,
sender_email TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
ai_draft TEXT,
draft_status TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO contact_messages
(sender_name, sender_email, subject, body)
VALUES
('Jordan', '[email protected]', 'Saturday appointment',
'Do you have any appointments available this Saturday?');
Build a defensive API boundary
The following file contains the transport seam and client together for readability. In a larger codebase, each public type can have its own file. cURL receives three-second connection and twenty-second overall limits. The client retries network failures, HTTP 429, and server errors, but never validation or authentication failures.
<?php
// src/AI/SmartRoutingClient.php
declare(strict_types=1);
namespace App\AI;
final class TransportException extends \RuntimeException {}
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function postJson(string $url, array $headers, array $body): HttpResponse;
}
final class CurlTransport implements Transport
{
public function postJson(string $url, array $headers, array $body): HttpResponse
{
$responseHeaders = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return $length;
},
]);
$bodyText = curl_exec($handle);
if ($bodyText === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException($message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $responseHeaders, $bodyText);
}
}
final readonly class DraftReply
{
public function __construct(public string $content) {}
}
final class AiFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
string $message,
) {
parent::__construct($message);
}
}
final class SmartRoutingClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions';
public function __construct(
private readonly Transport $transport,
private readonly string $token,
private readonly string $model,
private readonly \Closure $logger,
private readonly \Closure $sleeper,
) {}
public function draft(string $customerMessage): DraftReply
{
$payload = [
'model' => $this->model,
'messages' => [
[
'role' => 'system',
'content' => 'Draft a concise, courteous business reply. '
. 'Do not claim an action was completed. Do not invent '
. 'prices, availability, policies, or commitments. '
. 'Return reply text only for human review.',
],
[
'role' => 'user',
'content' => "Customer message:\n---\n"
. $customerMessage . "\n---",
],
],
];
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->postJson(
self::ENDPOINT,
[
'Authorization: Bearer ' . $this->token,
'Content-Type: application/json',
],
$payload,
);
} catch (TransportException $exception) {
($this->logger)('ai_transport_failure', [
'attempt' => $attempt,
'error_type' => $exception::class,
]);
if ($attempt === 3) {
throw new AiFailure(
'transport',
true,
'The drafting service could not be reached.'
);
}
($this->sleeper)($attempt);
continue;
}
if ($response->status >= 200 && $response->status < 300) {
return $this->mapResponse($response->body);
}
if (in_array($response->status, [401, 403], true)) {
throw new AiFailure(
'authentication',
false,
'The drafting service rejected its credential.'
);
}
$retryable = $response->status === 429
|| $response->status >= 500;
($this->logger)('ai_http_failure', [
'attempt' => $attempt,
'status' => $response->status,
]);
if (!$retryable || $attempt === 3) {
$kind = $response->status === 429 ? 'quota_or_rate_limit' : 'http';
throw new AiFailure(
$kind,
$retryable,
'The drafting service did not accept the request.'
);
}
($this->sleeper)($attempt);
}
throw new AiFailure('internal', false, 'Unexpected drafting failure.');
}
private function mapResponse(string $body): DraftReply
{
try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new AiFailure(
'invalid_response',
false,
'The drafting service returned invalid JSON.'
);
}
$content = $decoded['choices'][0]['message']['content'] ?? null;
if (!is_string($content) || trim($content) === '') {
throw new AiFailure(
'invalid_response',
false,
'The drafting response contained no usable text.'
);
}
return new DraftReply(trim($content));
}
}
The sleeper should implement a small capped backoff. For example, pass a closure that sleeps for one second after the first retry and two seconds after the second. Keeping it injectable makes tests immediate and deterministic. A 429 may mean a temporary rate limit or exhausted quota; the client makes only bounded attempts and then exposes a specific failure kind instead of looping.
Map the draft into the inbox domain
The application service retrieves only the customer’s message, requests a draft, and stores the result transactionally. Email addresses are deliberately excluded from the prompt because they add no drafting value.
<?php
// src/Inbox/InboxRepository.php and src/Inbox/DraftReplyService.php
declare(strict_types=1);
namespace App\Inbox;
use App\AI\SmartRoutingClient;
final class InboxRepository
{
public function __construct(private readonly \PDO $pdo) {}
public function messageBody(int $id): ?string
{
$statement = $this->pdo->prepare(
'SELECT body FROM contact_messages WHERE id = :id'
);
$statement->execute(['id' => $id]);
$body = $statement->fetchColumn();
return is_string($body) ? $body : null;
}
public function savePendingDraft(int $id, string $draft): void
{
$statement = $this->pdo->prepare(
"UPDATE contact_messages
SET ai_draft = :draft,
draft_status = 'pending_review',
updated_at = CURRENT_TIMESTAMP
WHERE id = :id"
);
$statement->execute(['draft' => $draft, 'id' => $id]);
if ($statement->rowCount() !== 1) {
throw new \RuntimeException('Message disappeared before draft save.');
}
}
}
final class DraftReplyService
{
public function __construct(
private readonly InboxRepository $inbox,
private readonly SmartRoutingClient $client,
) {}
public function create(int $messageId): string
{
$body = $this->inbox->messageBody($messageId);
if ($body === null) {
throw new \OutOfBoundsException('Contact message not found.');
}
$draft = $this->client->draft($body);
$this->inbox->savePendingDraft($messageId, $draft->content);
return $draft->content;
}
}
Expose a staff-only draft action
The endpoint below assumes the inbox’s login flow has already stored a staff identifier and CSRF token in the session. It accepts a message ID, creates a draft synchronously, and returns JSON for the review screen. It never accepts arbitrary prompt text and has no sending capability.
<?php
// public/draft.php
declare(strict_types=1);
use App\AI\AiFailure;
use App\AI\CurlTransport;
use App\AI\SmartRoutingClient;
use App\Inbox\DraftReplyService;
use App\Inbox\InboxRepository;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
session_start();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!isset($_SESSION['staff_user_id'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $csrf)) {
http_response_code(403);
echo json_encode(['error' => 'forbidden']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$messageId = filter_var($input['message_id'] ?? null, FILTER_VALIDATE_INT);
if ($messageId === false || $messageId < 1) {
http_response_code(422);
echo json_encode(['error' => 'invalid_message_id']);
exit;
}
$token = $_ENV['SMART_ROUTING_TOKEN'] ?? '';
$model = $_ENV['SMART_ROUTING_MODEL'] ?? '';
if ($token === '' || $model === '') {
http_response_code(503);
echo json_encode(['error' => 'service_not_configured']);
exit;
}
$database = $_ENV['APP_DB_PATH'] ?? 'var/inbox.sqlite';
if (!str_starts_with($database, '/')) {
$database = dirname(__DIR__) . '/' . $database;
}
$pdo = new PDO('sqlite:' . $database, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$logger = static function (string $event, array $context): void {
error_log(json_encode(
['event' => $event] + $context,
JSON_THROW_ON_ERROR
));
};
$sleeper = static function (int $attempt): void {
sleep(min($attempt, 2));
};
$client = new SmartRoutingClient(
new CurlTransport(),
$token,
$model,
$logger,
$sleeper,
);
$service = new DraftReplyService(new InboxRepository($pdo), $client);
try {
$draft = $service->create($messageId);
echo json_encode([
'draft' => $draft,
'status' => 'pending_review',
], JSON_THROW_ON_ERROR);
} catch (OutOfBoundsException) {
http_response_code(404);
echo json_encode(['error' => 'message_not_found']);
} catch (AiFailure $failure) {
http_response_code($failure->kind === 'authentication' ? 503 : 502);
echo json_encode([
'error' => 'draft_unavailable',
'reason' => $failure->kind,
'retryable' => $failure->retryable,
], JSON_THROW_ON_ERROR);
}
Test retries and response mapping without the network
A fake transport is better than a mocked cURL function: it verifies client behavior at the HTTP boundary while remaining deterministic. These tests cover recovery, authentication policy, and malformed success responses.
<?php
// tests/SmartRoutingClientTest.php
declare(strict_types=1);
use App\AI\AiFailure;
use App\AI\HttpResponse;
use App\AI\SmartRoutingClient;
use App\AI\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function postJson(string $url, array $headers, array $body): HttpResponse
{
$this->calls++;
return array_shift($this->responses);
}
}
final class SmartRoutingClientTest extends TestCase
{
private function client(FakeTransport $transport): SmartRoutingClient
{
return new SmartRoutingClient(
$transport,
'test-token',
'documented-model',
static fn () => null,
static fn () => null,
);
}
public function testRetriesServerFailureAndMapsDraft(): void
{
$fake = new FakeTransport([
new HttpResponse(503, [], '{}'),
new HttpResponse(200, [], json_encode([
'choices' => [[
'message' => ['content' => ' Thanks for contacting us. '],
]],
], JSON_THROW_ON_ERROR)),
]);
self::assertSame(
'Thanks for contacting us.',
$this->client($fake)->draft('Hello')->content,
);
self::assertSame(2, $fake->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new FakeTransport([new HttpResponse(401, [], '{}')]);
try {
$this->client($fake)->draft('Hello');
self::fail('Expected AiFailure');
} catch (AiFailure $failure) {
self::assertSame('authentication', $failure->kind);
self::assertFalse($failure->retryable);
self::assertSame(1, $fake->calls);
}
}
public function testRejectsMalformedSuccessfulResponse(): void
{
$fake = new FakeTransport([
new HttpResponse(200, [], '{"choices":[]}'),
]);
$this->expectException(AiFailure::class);
$this->client($fake)->draft('Hello');
}
}
vendor/bin/phpunit tests
Security, observability, and deployment
Treat customer messages as sensitive data. Send only what the model needs, keep transport encryption enabled, restrict the draft route to authenticated staff, retain CSRF protection, and define an appropriate retention policy for stored drafts. Render generated text with HTML escaping in the review interface. Model output is untrusted content, even when the prompt asks for plain text.
Logs should contain event names, HTTP status, attempt count, latency, and an internal message identifier where appropriate. They should not contain the bearer token, customer message, generated reply, email address, or complete remote body. Alert on sustained authentication failures, repeated 429 responses, malformed successful responses, and elevated server failures.
Deploy with cURL and PDO SQLite enabled, run tests, initialize the schema, and ensure only the PHP process can write the database directory. Inject the token and model through deployment secrets. If the token is regenerated, update the secret and restart or reload every PHP worker so no process retains obsolete configuration.
For a busier inbox, move draft creation to a job runner so the review page does not wait on model latency. Preserve the same client and domain boundary, add an idempotency check before saving, and expose a visible “drafting” state. Do not add a queue merely to disguise missing timeout and failure handling.
Common failures worth designing for
- 401 or 403: verify the service-scoped token, plan activation, and deployment secret. Do not retry automatically.
- 429: stop after bounded backoff. Ask the reviewer to retry later and inspect plan quota or request rate.
- 400-class validation failure: compare the model value and JSON request with the official documentation. Retrying an unchanged request will not repair it.
- Empty or malformed success: keep the existing draft untouched and report
invalid_response. - Timeout or 5xx: retry only a small number of times, then return a recoverable failure to the interface.
- Unreliable wording: require human review, show the original message beside the draft, and never convert a draft into an automatic commitment.
Final verification checklist
- The activated plan, documented model value, and current service token are configured.
- The minimal authenticated request succeeds against the exact chat-completions endpoint.
- No credential appears in source control, fixtures, application logs, or browser responses.
- PHPUnit verifies successful mapping, bounded retry, and non-retryable authentication failure.
- The staff route rejects unauthenticated, non-POST, invalid, and CSRF-mismatched requests.
- A generated reply is stored only as
pending_review. - The review screen escapes output and requires a person to edit or approve it before sending.
- Production monitoring distinguishes authentication, quota, transport, HTTP, and invalid-response failures.
The strongest feature here is not fluent text. It is the deliberate gap between suggestion and action. With a narrow API boundary, defensive mapping, honest failure states, and a review-only database state, AI can remove the blank-page burden from an ordinary inbox without removing the person whose judgment protects the customer relationship.