Tutorials

Native PHP 8.3: Build Client Security Dashboards with Automated Website Audits

Native PHP 8.3: Build Client Security Dashboards with Automated Website Audits

A security score is useful for a moment. A security dashboard is useful every week.

For a small web agency, the practical challenge is not merely detecting browser-security issues. It is keeping an understandable history for each client, turning recommendations into work, and knowing when an audit failed instead of silently displaying stale results.

This tutorial builds that workflow in Native PHP 8.3. A scheduled command audits approved client sites, stores normalized results in SQLite, creates remediation tasks, and feeds a small server-rendered dashboard. The integration uses the Website Security Analyzer for bounded, non-invasive analysis of public HTTPS and browser security posture. It must not be presented to clients as a penetration test, vulnerability scan, or guarantee of security.

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 an available Free, Plus, or Pro plan and complete its activation. Plan choice should reflect the number of client sites and intended audit frequency rather than influence application logic.

Next, open the official service documentation. Find the Service token panel and copy the service-scoped token shown there. This service requires authentication. Regenerating the token revokes the previously active token, so rotation must include updating the deployment environment before the next audit run.

The API accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because it keeps the credential out of URLs, access logs, and browser history. Do not send multiple authentication forms at once.

Confirm the exact endpoint

The integration makes this request:

POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website

Send a JSON body containing url. Before writing application code, make one minimal request from a trusted terminal:

curl --request POST \
  --url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://www.example.com"}'

Replace only the placeholder and example URL. Never paste the completed command into a ticket, shell transcript, screenshot, or source repository.

Store the credential in .env.local, exclude that file from version control, and let the operating system expose its values to PHP:

ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
APP_DB=/srv/security-dashboard/var/app.sqlite
APP_ENV=production

Native PHP does not load dotenv files automatically. For local development, export the file into the current shell with set -a; . ./.env.local; set +a. In production, configure the same variables through your process manager or secret store.

Architecture that fits a small agency

The design deliberately avoids a queue broker and JavaScript application. A cron-triggered PHP command selects due sites, calls the analyzer, and records either a successful audit or a structured failure. The dashboard only reads local data, so a slow external request never delays a client page.

  • Analyzer boundary: cURL transport, retry policy, JSON decoding, and defensive response mapping.
  • Persistence: clients, allowlisted sites, audit history, and remediation tasks in SQLite.
  • Runner: a command intended for cron or a systemd timer.
  • Dashboard: server-rendered history and open tasks, with no capability to submit arbitrary URLs.

SQLite is appropriate for one application instance and a modest audit schedule. If several workers or web nodes must write concurrently, retain the same domain boundary but migrate the repository to PostgreSQL.

The project layout is intentionally small:

security-dashboard/
├── bin/audit.php
├── public/index.php
├── src/Analyzer.php
├── tests/AnalyzerTest.php
├── var/app.sqlite
├── composer.json
└── schema.sql

Use Composer only for autoloading and development tests:

{
  "name": "agency/security-dashboard",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "Agency\\Security\\": "src/"
    }
  }
}

Run composer install, then create the database with sqlite3 var/app.sqlite < schema.sql:

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE clients (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE sites (
    id INTEGER PRIMARY KEY,
    client_id INTEGER NOT NULL REFERENCES clients(id),
    url TEXT NOT NULL UNIQUE,
    enabled INTEGER NOT NULL DEFAULT 1,
    next_audit_at TEXT NOT NULL
);

CREATE TABLE audits (
    id INTEGER PRIMARY KEY,
    site_id INTEGER NOT NULL REFERENCES sites(id),
    status TEXT NOT NULL CHECK (status IN ('succeeded', 'failed')),
    score REAL,
    findings_json TEXT,
    tls_json TEXT,
    recommendations_json TEXT,
    failure_code TEXT,
    created_at TEXT NOT NULL
);

CREATE TABLE tasks (
    id INTEGER PRIMARY KEY,
    audit_id INTEGER NOT NULL REFERENCES audits(id),
    site_id INTEGER NOT NULL REFERENCES sites(id),
    description TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'open'
        CHECK (status IN ('open', 'done')),
    UNIQUE (audit_id, description)
);

CREATE INDEX audits_site_created
    ON audits(site_id, created_at DESC);
CREATE INDEX tasks_site_status
    ON tasks(site_id, status);

Build a strict API boundary

The response contract provides a score, severity-grouped findings, TLS details, and recommendations. External JSON can still be incomplete or change shape during an upstream failure. The mapper therefore validates every top-level value and places an unexpected findings list under unclassified rather than guessing undocumented nested fields.

<?php
namespace Agency\Security;

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

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

final class CurlTransport implements Transport
{
    public function postJson(string $url, array $headers, string $body): HttpResponse
    {
        $received = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$received): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $received[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $bodyOut = curl_exec($handle);
        $error = $bodyOut === false ? curl_error($handle) : null;
        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse(
            $status,
            is_string($bodyOut) ? $bodyOut : '',
            $received,
            $error,
        );
    }
}

final readonly class AuditResult
{
    public function __construct(
        public ?float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $recommendations,
    ) {}
}

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $failureCode,
        public readonly ?int $httpStatus = null,
        string $message = 'Analyzer request failed',
    ) {
        parent::__construct($message);
    }
}

final class Analyzer
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    public function __construct(
        private Transport $transport,
        private string $token,
        private \Closure $sleep,
    ) {}

    public function analyze(string $url): AuditResult
    {
        $payload = json_encode(
            ['url' => $url],
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
        );

        for ($attempt = 0; $attempt < 3; $attempt++) {
            $response = $this->transport->postJson(self::ENDPOINT, [
                'Authorization: Bearer ' . $this->token,
                'Content-Type: application/json',
                'Accept: application/json',
            ], $payload);

            if ($response->transportError === null
                && $response->status >= 200
                && $response->status < 300) {
                return $this->map($response->body);
            }

            $retryable = $response->transportError !== null
                || $response->status === 429
                || $response->status >= 500;

            if (!$retryable) {
                throw new ApiFailure(
                    'non_retryable_http',
                    $response->status,
                    'Authentication, authorization, or request validation failed'
                );
            }

            if ($attempt === 2) {
                throw new ApiFailure(
                    $response->status === 429 ? 'rate_limited' : 'upstream_unavailable',
                    $response->status ?: null
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $delayMs = ctype_digit((string) $retryAfter)
                ? min(30000, (int) $retryAfter * 1000)
                : 250 * (2 ** $attempt);

            ($this->sleep)($delayMs);
        }

        throw new ApiFailure('unexpected_state');
    }

    private function map(string $body): AuditResult
    {
        try {
            $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            throw new ApiFailure('invalid_json');
        }

        if (!is_array($data)) {
            throw new ApiFailure('invalid_response');
        }

        $findings = is_array($data['findings'] ?? null)
            ? $data['findings'] : [];

        if (array_is_list($findings) && $findings !== []) {
            $findings = ['unclassified' => $findings];
        }

        return new AuditResult(
            is_numeric($data['score'] ?? null) ? (float) $data['score'] : null,
            $findings,
            is_array($data['tls'] ?? null) ? $data['tls'] : [],
            is_array($data['recommendations'] ?? null)
                ? array_values($data['recommendations']) : [],
        );
    }
}

A production logger should record the site identifier, attempt number, duration, HTTP status, failure code, and a correlation identifier if one is returned. It should never record the Authorization header or full response body. Even client URLs may be commercially sensitive, so prefer internal site IDs in routine logs.

Run audits and create practical tasks

Client URLs should be enrolled by an administrator, validated as HTTPS, and checked to ensure their resolved addresses are public. The scheduled process reads only this allowlist. That prevents the dashboard from becoming a server-side request forgery relay for arbitrary user input.

The runner stores success and failure separately. Recommendations become auditable tasks associated with the exact result that produced them:

<?php
require dirname(__DIR__) . '/vendor/autoload.php';

use Agency\Security\Analyzer;
use Agency\Security\ApiFailure;
use Agency\Security\CurlTransport;

$token = getenv('ANALYZER_TOKEN');
$dbPath = getenv('APP_DB');

if (!is_string($token) || $token === '' || !is_string($dbPath) || $dbPath === '') {
    throw new RuntimeException('Required environment configuration is missing');
}

$pdo = new PDO('sqlite:' . $dbPath, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');

$analyzer = new Analyzer(
    new CurlTransport(),
    $token,
    static fn (int $ms) => usleep($ms * 1000),
);

$sites = $pdo->query(
    "SELECT id, url FROM sites
     WHERE enabled = 1 AND next_audit_at <= datetime('now')
     ORDER BY next_audit_at LIMIT 20"
)->fetchAll(PDO::FETCH_ASSOC);

foreach ($sites as $site) {
    try {
        $result = $analyzer->analyze($site['url']);

        $pdo->beginTransaction();
        $insert = $pdo->prepare(
            "INSERT INTO audits
             (site_id, status, score, findings_json, tls_json,
              recommendations_json, created_at)
             VALUES (?, 'succeeded', ?, ?, ?, ?, datetime('now'))"
        );
        $insert->execute([
            $site['id'],
            $result->score,
            json_encode($result->findingsBySeverity, JSON_THROW_ON_ERROR),
            json_encode($result->tls, JSON_THROW_ON_ERROR),
            json_encode($result->recommendations, JSON_THROW_ON_ERROR),
        ]);
        $auditId = (int) $pdo->lastInsertId();

        $task = $pdo->prepare(
            "INSERT OR IGNORE INTO tasks
             (audit_id, site_id, description) VALUES (?, ?, ?)"
        );
        foreach ($result->recommendations as $recommendation) {
            $description = is_string($recommendation)
                ? trim($recommendation)
                : json_encode($recommendation, JSON_THROW_ON_ERROR);

            if ($description !== '') {
                $task->execute([$auditId, $site['id'], $description]);
            }
        }

        $pdo->prepare(
            "UPDATE sites SET next_audit_at = datetime('now', '+7 days')
             WHERE id = ?"
        )->execute([$site['id']]);
        $pdo->commit();
    } catch (ApiFailure $failure) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        $pdo->prepare(
            "INSERT INTO audits
             (site_id, status, failure_code, created_at)
             VALUES (?, 'failed', ?, datetime('now'))"
        )->execute([$site['id'], $failure->failureCode]);

        error_log(json_encode([
            'event' => 'website_audit_failed',
            'site_id' => (int) $site['id'],
            'failure_code' => $failure->failureCode,
            'http_status' => $failure->httpStatus,
        ], JSON_THROW_ON_ERROR));
    }
}

Unexpected programming or database exceptions should escape so the scheduler marks the run as failed. Catching every Throwable would make operational defects look like ordinary API failures.

Render history without calling the API

The dashboard route accepts a client ID, not a URL. In a real application, derive that client ID from the authenticated user’s authorization scope rather than trusting the query string alone.

<?php
$clientId = filter_input(INPUT_GET, 'client', FILTER_VALIDATE_INT);
if (!$clientId) {
    http_response_code(400);
    exit('Invalid client');
}

$stmt = $pdo->prepare(
    "SELECT s.url, a.status, a.score, a.failure_code, a.created_at
     FROM sites s
     JOIN audits a ON a.site_id = s.id
     WHERE s.client_id = ?
     ORDER BY a.created_at DESC LIMIT 50"
);
$stmt->execute([$clientId]);
$audits = $stmt->fetchAll(PDO::FETCH_ASSOC);

$tasks = $pdo->prepare(
    "SELECT t.description, s.url
     FROM tasks t JOIN sites s ON s.id = t.site_id
     WHERE s.client_id = ? AND t.status = 'open'
     ORDER BY t.id DESC"
);
$tasks->execute([$clientId]);

function h(mixed $value): string {
    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

echo '<h2>Security posture history</h2><ul>';
foreach ($audits as $audit) {
    $summary = $audit['status'] === 'succeeded'
        ? 'Score: ' . ($audit['score'] ?? 'not supplied')
        : 'Audit unavailable: ' . $audit['failure_code'];

    echo '<li><strong>' . h($audit['url']) . '</strong> — '
        . h($summary) . ' — ' . h($audit['created_at']) . '</li>';
}
echo '</ul><h2>Open remediation tasks</h2><ul>';
foreach ($tasks as $task) {
    echo '<li><strong>' . h($task['url']) . '</strong> — '
        . h($task['description']) . '</li>';
}
echo '</ul>';

The same escaping rule must be applied when rendering finding and TLS details. Treat all upstream strings as untrusted content, even though they came from an authenticated API.

Test retries and mapping without network access

A deterministic fake transport makes failure paths fast and reproducible. This test verifies rate-limit recovery, Bearer authentication, defensive grouping, and the absence of unnecessary extra calls:

<?php
use Agency\Security\Analyzer;
use Agency\Security\HttpResponse;
use Agency\Security\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public array $calls = [];

    public function __construct(private array $responses) {}

    public function postJson(string $url, array $headers, string $body): HttpResponse
    {
        $this->calls[] = compact('url', 'headers', 'body');
        return array_shift($this->responses);
    }
}

final class AnalyzerTest extends TestCase
{
    public function testRetriesRateLimitAndMapsResult(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '0']),
            new HttpResponse(200, json_encode([
                'score' => 82,
                'findings' => ['high' => [['message' => 'Example']]],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported finding'],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $delays = [];
        $analyzer = new Analyzer(
            $fake,
            'test-token',
            static function (int $ms) use (&$delays): void {
                $delays[] = $ms;
            },
        );

        $result = $analyzer->analyze('https://www.example.com');

        self::assertSame(82.0, $result->score);
        self::assertArrayHasKey('high', $result->findingsBySeverity);
        self::assertCount(2, $fake->calls);
        self::assertSame([0], $delays);
        self::assertContains(
            'Authorization: Bearer test-token',
            $fake->calls[0]['headers']
        );
    }
}

Add companion tests for invalid JSON, exhausted 500 responses, transport errors, and immediate failure on 400, 401, or 403. Run the suite with vendor/bin/phpunit tests. Test fixtures must use obvious fake tokens.

Security, operations, and deployment

Protect the dashboard with authentication and per-client authorization. Use HTTPS, secure session cookies, CSRF protection on task updates, prepared SQL statements, output escaping, and a restrictive Content Security Policy. Enrollment should reject non-HTTPS URLs, credentials embedded in URLs, localhost names, and private, loopback, link-local, or reserved addresses after DNS resolution.

Schedule one runner instance at a time. With cron, use a non-blocking lock such as flock -n /run/security-dashboard-audit.lock php /srv/security-dashboard/bin/audit.php. Provision writable access only to var; source, Composer files, and the web document root should not be writable by the web process. Serve only public through PHP-FPM and your web server. The built-in PHP server is for local verification, not production.

Alert on consecutive failures, elevated rate limiting, unusually long durations, and sites whose last successful audit exceeds the expected interval. A failed run must remain visible beside history; never replace the most recent successful score with zero.

Common failures

  • 401 or 403: verify plan activation and the service-scoped token. If it was regenerated, the previous token is revoked. Do not retry blindly.
  • 400-class validation failure: confirm the request uses JSON with exactly the required url value and an appropriate content type.
  • 429: honor Retry-After when supplied, keep retries bounded, and reduce audit frequency or concurrency.
  • Timeout or 500-class response: retry briefly with backoff, then record an upstream failure without erasing earlier results.
  • Invalid JSON or missing fields: preserve a structured failure or nullable value. Do not fabricate a score, TLS state, or recommendation.
  • SQLite lock contention: keep transactions short, retain WAL and a busy timeout, and prevent overlapping runners.

Final verification checklist

  1. Activate a Free, Plus, or Pro plan and obtain the token from the documentation page’s Service token panel.
  2. Confirm the minimal POST request succeeds against an approved public HTTPS site.
  3. Keep the token in environment-backed configuration and verify that logs never contain it.
  4. Run the schema, enroll a client and allowlisted site, then execute bin/audit.php.
  5. Confirm the audit stores the score, grouped findings, TLS details, and recommendations defensively.
  6. Confirm recommendations appear as open tasks and repeated page loads make no external API calls.
  7. Exercise 400, 401, 429, 500, timeout, and malformed-response tests.
  8. Verify authentication, client isolation, output escaping, scheduler locking, backups, and failure alerts before launch.

The result is more than a scorecard. It is a restrained operational loop: observe a public website’s HTTPS and browser-security posture, retain evidence over time, convert recommendations into accountable work, and expose uncertainty honestly when an audit cannot complete. That honesty is what makes the dashboard useful. It helps an agency improve client sites without pretending a bounded website analysis is something it is not.

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.