Native PHP 8.3: Agency Client Dashboard with Historical Security Scans and Remediation
A security dashboard becomes useful when it answers three questions quickly: what changed, what matters now, and who should fix it. A single score cannot do that. A small agency needs historical scans, severity-grouped findings, TLS context, and remediation tasks that survive beyond the latest API response.
This tutorial builds that workflow in Native PHP 8.3. A command scans each client through the Website Security Analyzer, stores immutable results in SQLite, creates remediation tasks, and exposes a read-only dashboard. The analyzer performs bounded, non-invasive inspection of public HTTPS and browser security posture. Its output is operational guidance, not a penetration test, vulnerability certification, or substitute for an authorized security assessment.
Get access and copy the service token
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Website Security Analyzer service page.
- Choose the available Free, Plus, or Pro plan and complete activation.
- Open the official service documentation.
- Find the Service token panel and copy its service-scoped token.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses the Bearer form because credentials in query strings can leak into access logs, browser history, and monitoring systems.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment: update the application secret, restart affected workers, verify a scan, and only then consider the rollout complete.
Confirm the exact API call
The integration uses POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website with a JSON body containing url. Before writing application code, test the token against a public HTTPS site you control:
curl --fail-with-body \
--connect-timeout 3 \
--max-time 15 \
-X POST \
-H "Authorization: Bearer YOUR_SERVICE_TOKEN" \
-H "Content-Type: application/json" \
--data '{"url":"https://client.example"}' \
https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Do not paste the response or token into public issue trackers. Inspect the current documentation and successful response before finalizing production mapping, especially if the service evolves.
Store configuration outside source control
Create .env for local development and exclude it from Git. In production, inject the same names through the process manager or secret store.
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_ANALYZER_ENDPOINT=https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
DATABASE_PATH=/var/lib/agency-security/dashboard.sqlite
Architecture and project layout
Scanning belongs in a scheduled command rather than a browser request. That keeps upstream latency and rate limits away from page rendering, while the dashboard remains available during an API outage. SQLite is appropriate for one small deployment process; use a server database if several application instances must write concurrently.
agency-security/
├── bin/scan.php
├── public/index.php
├── src/App.php
├── tests/AnalyzerClientTest.php
├── var/
├── .env
├── .gitignore
├── bootstrap.php
├── composer.json
└── phpunit.xml
Install PHP 8.3 with cURL, PDO SQLite, JSON, and Composer. PHPUnit is the only third-party dependency:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"files": ["src/App.php"]
}
}
composer install
mkdir -p var
chmod 750 var
printf '%s\n' '.env' 'var/*.sqlite*' > .gitignore
The bootstrap loader supports a deliberately narrow NAME=value format. Production does not need the file when the variables already exist.
<?php
// bootstrap.php
declare(strict_types=1);
$envFile = __DIR__ . '/.env';
if (is_file($envFile)) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) {
continue;
}
[$name, $value] = array_map('trim', explode('=', $line, 2));
if (getenv($name) === false) {
putenv($name . '=' . trim($value, "\"'"));
}
}
}
require __DIR__ . '/vendor/autoload.php';
Build a defensive API boundary
The transport owns cURL, timeouts, and headers. The client owns authentication, status handling, retries, and response mapping. Keeping those responsibilities separate gives tests a deterministic fake transport without making network calls.
The mapper uses the contract areas score, findings, tls, and recommendations, but validates their types at the boundary. If the documented response wraps these values, adjust only Analysis::fromPayload(); do not scatter response assumptions through controllers and templates.
<?php
// src/App.php
declare(strict_types=1);
namespace AgencySecurity;
record HttpResponse(int $status, array $headers, string $body) {}
interface Transport
{
public function post(string $url, array $headers, string $body): HttpResponse;
}
final class CurlTransport implements Transport
{
public function post(string $url, array $headers, string $body): HttpResponse
{
$responseHeaders = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 12000,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
]);
$bodyText = curl_exec($handle);
if ($bodyText === false) {
$message = curl_error($handle);
curl_close($handle);
throw new \RuntimeException('Analyzer transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $responseHeaders, $bodyText);
}
}
final readonly class Analysis
{
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tls,
public array $recommendations,
public array $raw
) {}
public static function fromPayload(array $payload): self
{
foreach (['score', 'findings', 'tls', 'recommendations'] as $key) {
if (!array_key_exists($key, $payload)) {
throw new \UnexpectedValueException("Missing response value: {$key}");
}
}
if (!is_int($payload['score']) && !is_float($payload['score'])) {
throw new \UnexpectedValueException('Score must be numeric');
}
if (!is_array($payload['findings']) ||
!is_array($payload['tls']) ||
!is_array($payload['recommendations'])) {
throw new \UnexpectedValueException('Malformed analyzer response');
}
foreach ($payload['findings'] as $severity => $items) {
if (!is_string($severity) || !is_array($items)) {
throw new \UnexpectedValueException('Findings must be grouped by severity');
}
}
return new self(
(float) $payload['score'],
$payload['findings'],
$payload['tls'],
$payload['recommendations'],
$payload
);
}
}
final class AnalyzerClient
{
public function __construct(
private Transport $transport,
private string $endpoint,
private string $token,
private $sleep = null
) {
$this->sleep ??= static fn(int $milliseconds) =>
usleep($milliseconds * 1000);
}
public function analyze(string $url): Analysis
{
$requestBody = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
$retryable = [408, 429, 500, 502, 503, 504];
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->post($this->endpoint, [
'Authorization: Bearer ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
], $requestBody);
} catch (\RuntimeException $error) {
if ($attempt === 3) {
throw $error;
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
$payload = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($payload)) {
throw new \UnexpectedValueException('Response is not a JSON object');
}
return Analysis::fromPayload($payload);
}
if (!in_array($response->status, $retryable, true) || $attempt === 3) {
throw new \RuntimeException(
'Analyzer request failed with HTTP ' . $response->status
);
}
$retryAfter = $response->headers['retry-after'] ?? null;
$delay = is_string($retryAfter) && ctype_digit($retryAfter)
? min(5000, (int) $retryAfter * 1000)
: 250 * (2 ** ($attempt - 1));
($this->sleep)($delay);
}
throw new \LogicException('Retry loop ended unexpectedly');
}
}
Validation and authentication failures are intentionally not retried. Repeating a bad URL or revoked token wastes quota and delays a useful alert. Retryable transport and server failures receive two bounded backoff delays; Retry-After is honored when it is a numeric number of seconds, with a five-second ceiling for this foreground command.
Persist history and remediation tasks
Each successful result is immutable. Recommendations become open tasks attached to that scan. Because the contract does not promise a particular recommendation-object shape here, the label function extracts scalar leaf values instead of guessing field names.
<?php
// Append to src/App.php
namespace AgencySecurity;
final class Store
{
public function __construct(private \PDO $db)
{
$this->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->db->exec(
'CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY,
client TEXT NOT NULL,
url TEXT NOT NULL,
state TEXT NOT NULL,
score REAL,
payload TEXT,
error TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
scan_id INTEGER NOT NULL REFERENCES scans(id),
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT "open"
);'
);
}
public function saveSuccess(string $client, string $url, Analysis $analysis): void
{
$this->db->beginTransaction();
try {
$statement = $this->db->prepare(
'INSERT INTO scans(client,url,state,score,payload,created_at)
VALUES(?,?,"complete",?,?,?)'
);
$statement->execute([
$client,
$url,
$analysis->score,
json_encode($analysis->raw, JSON_THROW_ON_ERROR),
gmdate('c'),
]);
$scanId = (int) $this->db->lastInsertId();
$task = $this->db->prepare(
'INSERT INTO tasks(scan_id,description) VALUES(?,?)'
);
foreach ($analysis->recommendations as $recommendation) {
$task->execute([$scanId, self::label($recommendation)]);
}
$this->db->commit();
} catch (\Throwable $error) {
$this->db->rollBack();
throw $error;
}
}
public function saveFailure(string $client, string $url, string $error): void
{
$statement = $this->db->prepare(
'INSERT INTO scans(client,url,state,error,created_at)
VALUES(?,?,"failed",?,?)'
);
$statement->execute([$client, $url, $error, gmdate('c')]);
}
public function scans(): array
{
return $this->db->query(
'SELECT * FROM scans ORDER BY created_at DESC LIMIT 100'
)->fetchAll(\PDO::FETCH_ASSOC);
}
public function tasks(int $scanId): array
{
$statement = $this->db->prepare(
'SELECT description,status FROM tasks WHERE scan_id=? ORDER BY id'
);
$statement->execute([$scanId]);
return $statement->fetchAll(\PDO::FETCH_ASSOC);
}
private static function label(mixed $value): string
{
if (is_scalar($value)) {
return trim((string) $value);
}
if (is_array($value)) {
$parts = [];
array_walk_recursive($value, static function ($item) use (&$parts): void {
if (is_scalar($item)) {
$parts[] = trim((string) $item);
}
});
return implode(' — ', array_filter($parts));
}
return 'Review analyzer recommendation';
}
}
Create the scan command
The command accepts a client name and URL. It requires HTTPS, rejects literal IP addresses, records failures without exposing response bodies or credentials, and returns a non-zero exit code for schedulers.
<?php
// bin/scan.php
declare(strict_types=1);
use AgencySecurity\{AnalyzerClient, CurlTransport, Store};
require dirname(__DIR__) . '/bootstrap.php';
[$script, $client, $url] = $argv + [null, null, null];
$host = is_string($url) ? parse_url($url, PHP_URL_HOST) : null;
if (!$client || !filter_var($url, FILTER_VALIDATE_URL) ||
parse_url($url, PHP_URL_SCHEME) !== 'https' ||
!is_string($host) || filter_var($host, FILTER_VALIDATE_IP)) {
fwrite(STDERR, "Usage: php bin/scan.php CLIENT https://public-hostname\n");
exit(2);
}
$token = getenv('SECURITY_ANALYZER_TOKEN');
$endpoint = getenv('SECURITY_ANALYZER_ENDPOINT');
$database = getenv('DATABASE_PATH');
if (!$token || !$endpoint || !$database) {
fwrite(STDERR, "Required environment configuration is missing\n");
exit(2);
}
$store = new Store(new PDO('sqlite:' . $database));
$clientApi = new AnalyzerClient(new CurlTransport(), $endpoint, $token);
try {
$analysis = $clientApi->analyze($url);
$store->saveSuccess($client, $url, $analysis);
fwrite(STDOUT, "Scan stored with score {$analysis->score}\n");
} catch (Throwable $error) {
$store->saveFailure($client, $url, $error->getMessage());
error_log(json_encode([
'event' => 'security_scan_failed',
'client' => $client,
'host' => $host,
'error_type' => $error::class,
], JSON_THROW_ON_ERROR));
fwrite(STDERR, "Scan failed; see application logs\n");
exit(1);
}
Render a safe, read-only dashboard
Escape every stored value, including upstream content. The dashboard shows recent history and open work without claiming that a good score proves the site secure.
<?php
// public/index.php
declare(strict_types=1);
use AgencySecurity\Store;
require dirname(__DIR__) . '/bootstrap.php';
$store = new Store(new PDO('sqlite:' . getenv('DATABASE_PATH')));
$escape = static fn(mixed $value): string =>
htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
header('Content-Type: text/html; charset=utf-8');
echo '<h1>Client security history</h1>';
echo '<p>Non-invasive posture checks, not penetration-test results.</p>';
foreach ($store->scans() as $scan) {
echo '<article>';
echo '<h2>' . $escape($scan['client']) . '</h2>';
echo '<p>' . $escape($scan['url']) . ' — ' .
$escape($scan['created_at']) . '</p>';
echo '<p>State: ' . $escape($scan['state']) . '</p>';
if ($scan['state'] === 'complete') {
echo '<p>Score: ' . $escape($scan['score']) . '</p>';
$payload = json_decode($scan['payload'], true, 512, JSON_THROW_ON_ERROR);
echo '<h3>Findings by severity</h3>';
echo '<pre>' . $escape(json_encode(
$payload['findings'],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
)) . '</pre>';
echo '<h3>TLS details</h3>';
echo '<pre>' . $escape(json_encode(
$payload['tls'],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
)) . '</pre>';
echo '<h3>Remediation tasks</h3><ul>';
foreach ($store->tasks((int) $scan['id']) as $task) {
echo '<li>' . $escape($task['status']) . ': ' .
$escape($task['description']) . '</li>';
}
echo '</ul>';
} else {
echo '<p>The scan failed. Operators should consult structured logs.</p>';
}
echo '</article>';
}
Test retries and mapping without the network
A deterministic fake verifies the outgoing authentication, JSON body, retry count, and mapped domain result. No real token belongs in fixtures.
<?php
// tests/AnalyzerClientTest.php
declare(strict_types=1);
use AgencySecurity\{AnalyzerClient, HttpResponse, Transport};
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public array $requests = [];
public function __construct(private array $responses) {}
public function post(string $url, array $headers, string $body): HttpResponse
{
$this->requests[] = compact('url', 'headers', 'body');
return array_shift($this->responses);
}
}
final class AnalyzerClientTest extends TestCase
{
public function testRetriesRateLimitAndMapsResponse(): void
{
$payload = [
'score' => 82,
'findings' => ['high' => [], 'medium' => [['check' => 'headers']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review browser security headers'],
];
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], '{}'),
new HttpResponse(200, [], json_encode($payload, JSON_THROW_ON_ERROR)),
]);
$delays = [];
$client = new AnalyzerClient(
$transport,
'https://service.test/analyze',
'TEST_TOKEN',
static function (int $ms) use (&$delays): void { $delays[] = $ms; }
);
$analysis = $client->analyze('https://client.example');
self::assertSame(82.0, $analysis->score);
self::assertCount(2, $transport->requests);
self::assertSame([1000], $delays);
self::assertContains(
'Authorization: Bearer TEST_TOKEN',
$transport->requests[0]['headers']
);
self::assertSame(
['url' => 'https://client.example'],
json_decode($transport->requests[0]['body'], true)
);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([
new HttpResponse(401, [], '{"error":"unauthorized"}'),
]);
$client = new AnalyzerClient(
$transport,
'https://service.test/analyze',
'TEST_TOKEN',
static function (): void {}
);
$this->expectException(RuntimeException::class);
try {
$client->analyze('https://client.example');
} finally {
self::assertCount(1, $transport->requests);
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="bootstrap.php" colors="true">
<testsuites>
<testsuite name="Agency Security">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Deployment, observability, and common failures
Run tests, perform one controlled scan, and serve only the public directory. Schedule scans at a cadence supported by the active plan, staggering clients rather than creating a burst.
vendor/bin/phpunit
php bin/scan.php "Acme Bakery" https://client.example
php -S 127.0.0.1:8080 -t public
# Example cron entry: one client every day at 02:17
17 2 * * * cd /srv/agency-security && /usr/bin/php bin/scan.php \
"Acme Bakery" https://client.example >>/var/log/agency-security.log 2>&1
Protect the dashboard with the web server’s authentication and HTTPS. Give the PHP user write access only to the database directory, keep .env outside the document root, back up the SQLite database, and never log tokens, authorization headers, or complete upstream bodies. Alert on repeated security_scan_failed events and on clients whose latest successful scan becomes unexpectedly old.
An HTTP 401 or 403 usually means the token is missing, revoked, copied incorrectly, or not active for this service. HTTP 400 points to request validation and should not be retried. HTTP 429 means the plan’s current quota or rate limit needs attention. Timeouts and selected 5xx responses receive bounded retries, but persistent failures remain visible as failed scan history. A mapping exception means the live response no longer matches the boundary contract; compare it with the official documentation before changing the mapper.
Final verification checklist
- The account and Free, Plus, or Pro service plan are active.
- The service-scoped token comes from the documentation’s Service token panel.
- No real credential appears in Git, tests, logs, screenshots, or URLs.
- The request is a JSON
POSTcontainingurlat the exact documented endpoint. - Connection and total response times are bounded.
- Authentication and validation failures are not retried.
- Rate limits and transient failures use limited backoff.
- Score, severity-grouped findings, TLS details, and recommendations are validated at one boundary.
- Successful and failed scans both create useful historical records.
- Recommendations become visible remediation tasks.
- The dashboard escapes upstream and stored content.
- The interface clearly says that results are not a penetration test.
The durable value is not the latest score. It is the chain from observation to accountable work: a bounded scan, a preserved historical record, a practical remediation queue, and evidence that the next scan improved—or challenged—the team’s assumptions.