Tutorials

Native PHP: Triage Support Tickets Automatically with Smart Routing AI

Native PHP: Triage Support Tickets Automatically with Smart Routing AI

A contact form looks simple until every message lands in the same inbox. Sales enquiries wait behind password problems, billing questions bounce between people, and genuinely risky messages receive attention only when someone happens to notice them.

The useful application of AI here is not an elaborate chatbot. It is a narrow decision service: inspect a message, assign one controlled queue, record the reasoning, and fall back safely whenever the model or network cannot be trusted. This tutorial builds that service in Native PHP 8.3 using cURL, SQLite through PDO, and the Smart Routing AI Model.

Get access before writing integration code

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Smart Routing AI Model service page. Choose an available Free, Plus, or Pro plan and complete its activation.
  3. Visit the official service documentation. Find the Service token panel and copy the service-scoped token shown there.
  4. Store that token in the project’s environment configuration. This service requires a token; it is not an anonymous endpoint.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed instance, restart the relevant PHP workers, verify the new token, and remove any obsolete secret from your deployment system.

Confirm the endpoint with a minimal request

The exact API call 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 the standard OpenAI-style response.

The model identifier available to an account can depend on the activated plan. Copy the supported identifier from the official documentation or plan configuration and substitute it for YOUR_PLAN_MODEL; guessing a model name would turn a deployment check into a configuration bug.

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": "Classify this contact request: I need a copy of last month'\''s invoice."
      }
    ]
  }'

A successful response should contain assistant content at choices[0].message.content. The application will validate that path rather than assuming every successful HTTP response contains usable output.

Create a non-public .env file after this smoke test:

SMART_ROUTING_TOKEN=YOUR_SERVICE_TOKEN
SMART_ROUTING_MODEL=YOUR_PLAN_MODEL
DB_DSN=sqlite:/var/lib/contact-router/tickets.sqlite

Keep .env outside the web document root, exclude it from version control, and restrict it to the account running PHP. In a container or managed host, inject the same names through the platform’s secret facility instead of baking the file into an image.

Choose a small, failure-tolerant architecture

The browser submits to public/contact.php. The handler validates the form, asks a dedicated API client for a routing decision, and writes the message and decision to one SQLite transaction. The available destinations are sales, support, billing, abuse, and manual_review.

Classification is synchronous here because it keeps an ordinary small-team deployment understandable. The call has strict time limits, and an upstream failure does not lose the ticket: it routes to manual_review. A busier installation can move the same client behind a worker later, but that adds delivery, deduplication, and operational concerns that this project does not otherwise need.

Use this project structure:

contact-router/
├── .env
├── bootstrap.php
├── composer.json
├── database/
│   └── schema.sql
├── public/
│   └── contact.php
├── src/
│   └── SmartRouting.php
└── tests/
    └── SmartRoutingClientTest.php

PHP needs the cURL and PDO SQLite extensions. PHPUnit is the only development dependency:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  }
}
composer install
composer dump-autoload
mkdir -p /var/lib/contact-router
sqlite3 /var/lib/contact-router/tickets.sqlite < database/schema.sql
vendor/bin/phpunit tests

Build a defensive API boundary

The transport below performs one bounded HTTP operation. Retry policy belongs to the higher-level client so tests can exercise it without making real requests.

<?php
// src/SmartRouting.php

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers = [],
    ) {}
}

interface HttpTransport
{
    public function postJson(string $url, array $headers, array $body): HttpResponse;
}

class TransportException extends RuntimeException {}
class RoutingException extends RuntimeException {}
class RoutingConfigurationException extends RoutingException {}

final class CurlTransport implements HttpTransport
{
    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_MS => 2000,
            CURLOPT_TIMEOUT_MS => 8000,
            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;
            },
        ]);

        $raw = curl_exec($handle);
        if ($raw === 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, $raw, $responseHeaders);
    }
}

final readonly class RoutingDecision
{
    public function __construct(
        public string $queue,
        public float $confidence,
        public string $summary,
        public string $source = 'model',
    ) {}
}

The model receives a closed vocabulary and a request for JSON only. That prompt improves consistency, but it is not a security boundary. The returned content is still untrusted input.

<?php
final class SmartRoutingClient
{
    private const URL =
        'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions';

    public function __construct(
        private string $token,
        private string $model,
        private HttpTransport $transport,
        private Closure $logger,
        private Closure $sleeper,
    ) {}

    public function classify(string $message): RoutingDecision
    {
        $payload = [
            'model' => $this->model,
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'Route the contact message to exactly one queue: '
                        . 'sales, support, billing, or abuse. Return only JSON with '
                        . 'queue, confidence from 0 to 1, and a short summary.',
                ],
                ['role' => 'user', 'content' => $message],
            ],
        ];

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::URL,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Content-Type: application/json',
                    ],
                    $payload,
                );
            } catch (TransportException $exception) {
                ($this->logger)([
                    'outcome' => 'transport_error',
                    'attempt' => $attempt,
                ]);

                if ($attempt === 3) {
                    throw new RoutingException('Routing service unavailable');
                }

                ($this->sleeper)(2 ** ($attempt - 1));
                continue;
            }

            if (in_array($response->status, [408, 429], true)
                || $response->status >= 500) {
                ($this->logger)([
                    'outcome' => 'retryable_http_error',
                    'status' => $response->status,
                    'attempt' => $attempt,
                ]);

                if ($attempt === 3) {
                    throw new RoutingException('Routing service unavailable');
                }

                $retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
                    ? (int) $response->headers['retry-after']
                    : 2 ** ($attempt - 1);

                ($this->sleeper)(min(5, max(1, $retryAfter)));
                continue;
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new RoutingConfigurationException(
                    'Service token rejected'
                );
            }

            if ($response->status < 200 || $response->status >= 300) {
                throw new RoutingException(
                    'Non-retryable routing response: ' . $response->status
                );
            }

            return $this->mapResponse($response->body);
        }

        throw new RoutingException('Routing attempts exhausted');
    }

    private function mapResponse(string $body): RoutingDecision
    {
        try {
            $envelope = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
            $content = $envelope['choices'][0]['message']['content'] ?? null;

            if (!is_string($content)) {
                throw new UnexpectedValueException('Missing assistant content');
            }

            $result = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException|UnexpectedValueException $exception) {
            throw new RoutingException('Malformed routing response');
        }

        $queue = $result['queue'] ?? null;
        $confidence = $result['confidence'] ?? null;
        $summary = $result['summary'] ?? null;
        $allowed = ['sales', 'support', 'billing', 'abuse'];

        if (!in_array($queue, $allowed, true)
            || !is_int($confidence) && !is_float($confidence)
            || $confidence < 0 || $confidence > 1
            || !is_string($summary) || trim($summary) === '') {
            throw new RoutingException('Invalid routing decision');
        }

        if ($confidence < 0.65) {
            return new RoutingDecision(
                'manual_review',
                (float) $confidence,
                substr($summary, 0, 240),
                'low_confidence',
            );
        }

        return new RoutingDecision(
            $queue,
            (float) $confidence,
            substr($summary, 0, 240),
        );
    }
}

Only transient transport errors, HTTP 408, HTTP 429, and server errors receive bounded retries. Authentication and ordinary client errors are not retried: sending the same invalid request again wastes quota and delays the visitor. A numeric Retry-After value is respected but capped so one web request cannot remain open indefinitely.

Persist the ticket and its decision together

CREATE TABLE tickets (
    id TEXT PRIMARY KEY,
    email TEXT NOT NULL,
    message TEXT NOT NULL,
    queue TEXT NOT NULL,
    confidence REAL NOT NULL,
    route_summary TEXT NOT NULL,
    route_source TEXT NOT NULL,
    created_at TEXT NOT NULL
);

CREATE INDEX tickets_queue_created
    ON tickets (queue, created_at);
<?php
final class TicketRepository
{
    public function __construct(private PDO $pdo) {}

    public function save(
        string $id,
        string $email,
        string $message,
        RoutingDecision $decision,
    ): void {
        $statement = $this->pdo->prepare(
            'INSERT INTO tickets
             (id, email, message, queue, confidence, route_summary,
              route_source, created_at)
             VALUES
             (:id, :email, :message, :queue, :confidence, :summary,
              :source, :created_at)'
        );

        $statement->execute([
            'id' => $id,
            'email' => $email,
            'message' => $message,
            'queue' => $decision->queue,
            'confidence' => $decision->confidence,
            'summary' => $decision->summary,
            'source' => $decision->source,
            'created_at' => gmdate('c'),
        ]);
    }
}

Store the original message for the team that must answer it, but do not send the email address to the classifier when the message alone is sufficient. This reduces unnecessary disclosure. Retention and deletion rules should cover both the original text and the model-produced summary.

Wire configuration and the contact endpoint

<?php
// bootstrap.php
require __DIR__ . '/vendor/autoload.php';

$env = parse_ini_file(__DIR__ . '/.env', false, INI_SCANNER_RAW);
if ($env === false) {
    throw new RuntimeException('Environment configuration is missing');
}

foreach (['SMART_ROUTING_TOKEN', 'SMART_ROUTING_MODEL', 'DB_DSN'] as $key) {
    if (!isset($env[$key]) || $env[$key] === '') {
        throw new RuntimeException("Missing configuration: {$key}");
    }
}

$pdo = new PDO($env['DB_DSN'], null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$logger = static function (array $context): void {
    error_log(json_encode(
        ['event' => 'smart_routing'] + $context,
        JSON_THROW_ON_ERROR
    ));
};

$sleeper = static fn (int $seconds) => sleep($seconds);

return [
    'router' => new SmartRoutingClient(
        $env['SMART_ROUTING_TOKEN'],
        $env['SMART_ROUTING_MODEL'],
        new CurlTransport(),
        $logger,
        $sleeper,
    ),
    'tickets' => new TicketRepository($pdo),
];
<?php
// public/contact.php
declare(strict_types=1);

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 = (string) ($_POST['csrf'] ?? '');
$expected = (string) ($_SESSION['csrf'] ?? '');
if ($expected === '' || !hash_equals($expected, $csrf)) {
    http_response_code(403);
    echo json_encode(['error' => 'Invalid form session']);
    exit;
}

$email = trim((string) ($_POST['email'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));

if (!filter_var($email, FILTER_VALIDATE_EMAIL)
    || strlen($email) > 254
    || strlen($message) < 10
    || strlen($message) > 10000) {
    http_response_code(422);
    echo json_encode(['error' => 'Check the submitted fields']);
    exit;
}

$services = require dirname(__DIR__) . '/bootstrap.php';
$id = bin2hex(random_bytes(16));

try {
    try {
        $decision = $services['router']->classify($message);
    } catch (RoutingException $exception) {
        error_log(json_encode([
            'event' => 'smart_routing_fallback',
            'ticket_id' => $id,
            'exception' => $exception::class,
        ]));

        $decision = new RoutingDecision(
            'manual_review',
            0.0,
            'Automatic routing unavailable',
            'fallback',
        );
    }

    $services['tickets']->save($id, $email, $message, $decision);
} catch (PDOException $exception) {
    error_log(json_encode([
        'event' => 'ticket_persistence_failed',
        'ticket_id' => $id,
    ]));
    http_response_code(503);
    echo json_encode(['error' => 'Please try again later']);
    exit;
}

http_response_code(202);
echo json_encode(['ticket_id' => $id, 'status' => 'accepted']);

The form that posts here must create $_SESSION['csrf'] with bin2hex(random_bytes(32)) and include it in a hidden csrf field. Add an edge or application-level rate limit as well; CSRF protection does not prevent automated spam.

Test without calling the service

A fake transport makes routing tests fast and deterministic. It also proves that retry behavior does not accidentally become an infinite loop.

<?php
// tests/SmartRoutingClientTest.php
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    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
{
    public function testMapsBillingDecision(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(200, json_encode([
                'choices' => [[
                    'message' => ['content' => json_encode([
                        'queue' => 'billing',
                        'confidence' => 0.93,
                        'summary' => 'Customer requests an invoice copy',
                    ])],
                ]],
            ])),
        ]);

        $client = new SmartRoutingClient(
            'test-token',
            'test-model',
            $transport,
            static fn (array $context) => null,
            static fn (int $seconds) => null,
        );

        $decision = $client->classify('Please send last month’s invoice.');

        self::assertSame('billing', $decision->queue);
        self::assertSame(1, $transport->calls);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '1']),
            new HttpResponse(200, json_encode([
                'choices' => [[
                    'message' => ['content' => json_encode([
                        'queue' => 'support',
                        'confidence' => 0.88,
                        'summary' => 'Login assistance needed',
                    ])],
                ]],
            ])),
        ]);

        $client = new SmartRoutingClient(
            'test-token',
            'test-model',
            $transport,
            static fn (array $context) => null,
            static fn (int $seconds) => null,
        );

        self::assertSame(
            'support',
            $client->classify('I cannot sign in to my account.')->queue
        );
        self::assertSame(2, $transport->calls);
    }
}

Add further cases for malformed envelopes, invalid queue names, low confidence, HTTP 401, three consecutive transient failures, and database errors. Controller-level tests should confirm that routing failures still persist a manual_review ticket while persistence failures return HTTP 503.

Operate it as a production feature

Logs should contain a request or ticket identifier, HTTP status, attempt number, latency, outcome, and fallback source. They should never contain the bearer token, full contact message, raw upstream body, or email address. Alert on sustained authentication failures, rising fallback volume, repeated HTTP 429 responses, and persistence errors. A few fallbacks are resilience; a growing fallback queue is an incident.

Deploy with the web server’s document root set to public/. Ensure the PHP worker can read its secret configuration and write the SQLite database directory, while the web-server user cannot download either file. Run schema creation or migrations before directing traffic to the new release. Restart long-lived PHP-FPM workers after rotating environment secrets.

Common failures are usually concrete:

  • HTTP 401 or 403: verify the service-scoped token, plan activation, and whether someone regenerated the token.
  • HTTP 429: inspect quota usage and request volume. Limited retries may absorb a brief limit, but exhausted quota should remain visible.
  • HTTP 400 or 422: check the configured model identifier and OpenAI-compatible JSON shape; do not retry unchanged input.
  • Every ticket reaches manual review: inspect response-validation logs, confidence distribution, and whether the model is returning prose around the requested JSON.
  • SQLite locking or write failures: check directory permissions and concurrent write volume. If writes regularly contend, move the repository behind a server database without changing the routing boundary.

Final verification checklist

  • The account plan is active, and the documented model identifier is configured.
  • The token exists only in environment-backed secret configuration.
  • The minimal endpoint request succeeds with the deployed credential.
  • Sales, support, billing, and abuse examples reach their intended queues.
  • Low-confidence, malformed, timed-out, and quota-limited responses reach manual_review.
  • Authentication and validation failures are not blindly retried.
  • A database failure returns 503 instead of falsely confirming acceptance.
  • Logs expose operational outcomes without exposing messages or credentials.
  • PHPUnit tests pass before deployment, and the production fallback queue is monitored.

Smart routing earns its place when it makes an inbox calmer without making submissions fragile. Keep the model’s authority narrow, validate every decision, preserve the original ticket, and make uncertainty an explicit queue. The memorable production rule is simple: automation may choose the fast path, but it must never be allowed to erase the safe one.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.