Tutorials

Symfony Security Dashboard: Client Audits, History, and Remediation with AI Analyzer

Symfony Security Dashboard: Client Audits, History, and Remediation with AI Analyzer

A security scan is useful once. A security history is useful every week. For a small agency managing several client sites, the difference matters: a dashboard should show whether posture is improving, preserve previous results, and turn recommendations into work that someone can actually complete.

This tutorial builds that feature in a PHP 8.3+ Symfony application. Each scan runs in the background, calls the Website Security Analyzer, stores a normalized snapshot, and presents the score, severity-grouped findings, TLS details, and remediation tasks. The result is deliberately described as a bounded review of public HTTPS and browser security posture—not a penetration test or proof that a site is secure.

Get access and create a service token

  1. Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
  2. Open the Website Security Analyzer 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 displayed there.
  4. Store the token in environment-backed project configuration. Never commit it or paste it into logs, screenshots, fixtures, or source code.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update every running application and worker before assuming scans are healthy again.

Verify the exact HTTP contract

The operation is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. It accepts JSON containing url. Authentication may use a Bearer token, an X-API-Token header, or a token query parameter. This project uses the Bearer form so credentials do not appear in URLs or routine access logs.

curl --fail-with-body \
  --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://client.example"}'

Run that request with a site you are authorized to assess. Then place the credential in an uncommitted .env.local file:

WEBSITE_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default
DATABASE_URL="postgresql://app:[email protected]:5432/agency?serverVersion=16&charset=utf8"

Architecture that fits a small agency

A browser request should not wait while an external analysis runs. The controller therefore records a queued audit and dispatches a Messenger message. A worker calls the API, a boundary mapper validates the response, and a small persistence service completes or fails the audit. The dashboard reads only local data, so it remains responsive during API latency or outages.

Persisting JSON snapshots is a pragmatic trade-off. Security findings and TLS details may evolve more quickly than the dashboard schema, while score, status, URL, and timestamps remain useful relational columns. If reporting later needs queries across individual findings, promote those values into dedicated tables.

The relevant project structure is:

src/
  Controller/SecurityDashboardController.php
  Message/AnalyzeWebsite.php
  MessageHandler/AnalyzeWebsiteHandler.php
  Security/AnalysisResult.php
  Security/WebsiteAnalyzer.php
  Security/AnalyzerException.php
  Repository/AuditStore.php
templates/security/index.html.twig
tests/Security/WebsiteAnalyzerTest.php
config/packages/messenger.yaml
config/services.yaml

Starting from a Symfony application that already authenticates agency staff, install the first-party components:

composer require symfony/http-client symfony/messenger symfony/doctrine-messenger \
  symfony/orm-pack symfony/twig-bundle symfony/security-bundle
composer require --dev symfony/test-pack
php bin/console doctrine:migrations:migrate

Create an audit migration with columns for id, url, status, nullable score, findings_json, tls_json, tasks_json, nullable error_code, created_at, and nullable completed_at. Use your database platform’s JSON type where available and index created_at and status.

Build a strict API boundary

The application should not spread response-array assumptions through controllers and templates. A domain result validates the four required concepts and converts recommendations into open remediation tasks.

<?php
// src/Security/AnalysisResult.php
namespace App\Security;

final readonly class AnalysisResult
{
    public function __construct(
        public float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $tasks,
    ) {}

    public static function fromApi(array $data): self
    {
        if (!is_numeric($data['score'] ?? null)
            || !is_array($data['findings'] ?? null)
            || !is_array($data['tls'] ?? null)
            || !is_array($data['recommendations'] ?? null)) {
            throw new AnalyzerException('invalid_response');
        }

        $tasks = [];
        foreach ($data['recommendations'] as $recommendation) {
            if (is_string($recommendation) && trim($recommendation) !== '') {
                $tasks[] = ['title' => trim($recommendation), 'status' => 'open'];
            }
        }

        return new self(
            (float) $data['score'],
            $data['findings'],
            $data['tls'],
            $tasks,
        );
    }
}

This mapper intentionally does not invent fields inside findings or TLS data. It preserves those documented response sections while rejecting missing or incorrectly typed top-level values. If the official documentation changes its example payload, update this single boundary and its tests.

The client applies bounded timeouts and retries only transient transport failures, rate limits, and selected server failures. Authentication and validation errors fail immediately.

<?php
// src/Security/WebsiteAnalyzer.php
namespace App\Security;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
    ) {}

    public function analyze(string $url, string $auditId): AnalysisResult
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->token,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 20.0,
                    'max_duration' => 30.0,
                ]);
                $status = $response->getStatusCode();
                $body = $response->getContent(false);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException('transport_failure', previous: $e);
                }
                $this->backoff($attempt);
                continue;
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
                } catch (\JsonException $e) {
                    throw new AnalyzerException('invalid_json', previous: $e);
                }

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

                return AnalysisResult::fromApi($data);
            }

            if (in_array($status, [429, 502, 503, 504], true) && $attempt < 3) {
                $this->logger->warning('Analyzer request will be retried', [
                    'audit_id' => $auditId,
                    'status' => $status,
                    'attempt' => $attempt,
                ]);
                $this->backoff($attempt);
                continue;
            }

            $code = match ($status) {
                401, 403 => 'authentication_failure',
                400, 422 => 'request_rejected',
                429 => 'rate_limited',
                default => 'upstream_failure',
            };
            throw new AnalyzerException($code);
        }

        throw new AnalyzerException('upstream_failure');
    }

    private function backoff(int $attempt): void
    {
        usleep(250_000 * (2 ** ($attempt - 1)));
    }
}

Do not log response bodies indiscriminately: findings can expose operational details, and error bodies may include request context. Audit identifiers, status codes, attempts, durations, and stable failure codes are enough for most operational diagnosis.

Wire the token through Symfony’s container:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true

  App\:
    resource: '../src/'

  App\Security\WebsiteAnalyzer:
    arguments:
      $token: '%env(WEBSITE_ANALYZER_TOKEN)%'

Queue scans and preserve their outcome

AuditStore, complete($id, AnalysisResult $result), fail($id, $code), and recent(). Implement these with an injected Doctrine DBAL Connection, parameterized statements, json_encode(..., JSON_THROW_ON_ERROR), and immutable UTC timestamps. Never concatenate the submitted URL into SQL.

<?php
// src/Message/AnalyzeWebsite.php
namespace App\Message;

final readonly class AnalyzeWebsite
{
    public function __construct(public string $auditId, public string $url) {}
}

// src/MessageHandler/AnalyzeWebsiteHandler.php
namespace App\MessageHandler;

use App\Message\AnalyzeWebsite;
use App\Repository\AuditStore;
use App\Security\AnalyzerException;
use App\Security\WebsiteAnalyzer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class AnalyzeWebsiteHandler
{
    public function __construct(
        private WebsiteAnalyzer $analyzer,
        private AuditStore $audits,
    ) {}

    public function __invoke(AnalyzeWebsite $message): void
    {
        try {
            $result = $this->analyzer->analyze(
                $message->url,
                $message->auditId
            );
            $this->audits->complete($message->auditId, $result);
        } catch (AnalyzerException $e) {
            $this->audits->fail($message->auditId, $e->getMessage());
        }
    }
}

The client already owns its short retry policy, so Messenger must not multiply those attempts:

# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 0
    routing:
      App\Message\AnalyzeWebsite: async

Add the protected dashboard

Accept only absolute HTTPS URLs, reject embedded credentials, require CSRF protection, and keep the route behind the application’s existing staff authentication. For an agency, a client allowlist is even better: it prevents a valid staff account from turning the dashboard into a general-purpose scanner.

<?php
// src/Controller/SecurityDashboardController.php
namespace App\Controller;

use App\Message\AnalyzeWebsite;
use App\Repository\AuditStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;

#[IsGranted('ROLE_AUDITOR')]
final class SecurityDashboardController extends AbstractController
{
    #[Route('/security', name: 'security_dashboard', methods: ['GET'])]
    public function index(AuditStore $audits): Response
    {
        return $this->render('security/index.html.twig', [
            'audits' => $audits->recent(),
        ]);
    }

    #[Route('/security/audits', name: 'security_audit_create', methods: ['POST'])]
    public function create(
        Request $request,
        AuditStore $audits,
        MessageBusInterface $bus,
    ): Response {
        if (!$this->isCsrfTokenValid('new-audit', $request->request->getString('_token'))) {
            throw $this->createAccessDeniedException();
        }

        $url = trim($request->request->getString('url'));
        $parts = parse_url($url);
        if (!filter_var($url, FILTER_VALIDATE_URL)
            || ($parts['scheme'] ?? '') !== 'https'
            || isset($parts['user'])
            || !isset($parts['host'])) {
            throw $this->createNotFoundException('A public HTTPS URL is required.');
        }

        $id = Uuid::v7()->toRfc4122();
        $audits->create($id, $url);
        $bus->dispatch(new AnalyzeWebsite($id, $url));

        return $this->redirectToRoute('security_dashboard');
    }
}

The Twig template can render a submission form followed by recent audits. For completed rows, display the score, iterate findings_json by severity, render TLS key/value data defensively, and show each item in tasks_json with its status. For queued and failed rows, show the stored state and safe failure code instead of an empty report. Twig’s automatic escaping must remain enabled.

Test success and failure deterministically

MockHttpClient verifies the outbound contract without contacting the service:

<?php
// tests/Security/WebsiteAnalyzerTest.php
namespace App\Tests\Security;

use App\Security\WebsiteAnalyzer;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteAnalyzerTest extends TestCase
{
    public function testMapsAValidResponse(): void
    {
        $response = new MockResponse(json_encode([
            'score' => 82,
            'findings' => ['high' => [], 'medium' => [['name' => 'Example']]],
            'tls' => ['enabled' => true],
            'recommendations' => ['Review the reported browser policy.'],
        ], JSON_THROW_ON_ERROR), ['http_code' => 200]);

        $http = new MockHttpClient(function (string $method, string $url, array $options) use ($response) {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
                $url
            );
            self::assertSame(['url' => 'https://client.example'], $options['json']);
            self::assertContains('Authorization: Bearer test-token', $options['headers']);
            return $response;
        });

        $result = (new WebsiteAnalyzer($http, new NullLogger(), 'test-token'))
            ->analyze('https://client.example', 'audit-1');

        self::assertSame(82.0, $result->score);
        self::assertSame('open', $result->tasks[0]['status']);
    }
}

Add cases for malformed JSON, a missing required section, immediate failure on 401, and bounded retries on 429. Test the controller with an authenticated ROLE_AUDITOR user, including invalid CSRF tokens, HTTP URLs, credential-bearing URLs, and unauthorized access. Test the handler with fake analyzer and store collaborators so success and failure transitions are explicit.

Deploy, observe, and troubleshoot

Run migrations during deployment, inject WEBSITE_ANALYZER_TOKEN through the hosting platform’s secret store, and restart both web processes and workers after rotation. Run the consumer under systemd, Supervisor, or the platform’s worker facility:

php bin/console messenger:consume async \
  --time-limit=3600 \
  --memory-limit=256M

Alert on growing queue depth, repeated authentication_failure, elevated rate_limited outcomes, and scans stuck in queued beyond an expected operational window. Retain audit history according to client agreements, restrict database access, and define a deletion policy rather than accumulating findings indefinitely.

Common failures are usually legible: authentication_failure suggests a missing, revoked, or stale token; request_rejected points to the submitted URL or current documented contract; rate_limited means work should be spaced according to the active plan; and invalid_response requires comparing the boundary mapper with the official documentation. A permanently queued audit usually means the Messenger worker is absent, stopped, or connected to a different database.

Final verification checklist

  • The token came from the documentation page’s Service token panel and exists only in environment-backed configuration.
  • The client sends POST to the exact analyzer endpoint with JSON containing url.
  • Only authorized staff can submit scans or view client history.
  • Requests have bounded timeouts, and only transient failures receive limited backoff retries.
  • Scores, severity-grouped findings, TLS details, and remediation tasks survive worker restarts as local snapshots.
  • Tests cover valid mapping, malformed data, authentication failure, rate limiting, controller validation, and state transitions.
  • The dashboard calls the result a bounded, non-invasive posture analysis—not a penetration test.

The most valuable output is not the score at the top of the page. It is the durable chain from observation to recommendation, from recommendation to task, and from one audit to the next. That turns a remote analyzer into an accountable client service: understandable today, comparable tomorrow, and useful long after the first scan finishes.

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.