Tutorials

Symfony Security Dashboard: Empower Clients with Auto-Generated Remediation Tasks

Symfony Security Dashboard: Empower Clients with Auto-Generated Remediation Tasks

A security scan becomes valuable only when somebody can act on it. For a small agency, a raw JSON response is not enough: account managers need history, developers need prioritized work, and clients need an honest view of what was checked.

This tutorial builds that workflow in Symfony and PHP 8.3. Each approved client site can be queued for bounded, non-invasive analysis of its public HTTPS and browser security posture. The application records every result, presents severity-grouped findings and TLS details, and converts recommendations into remediation tasks. It deliberately describes the result as a posture assessment, not a penetration test.

Get access and verify the service

First, register an account, or sign in if you already have one. Open the Website Security Analyzer service page, choose an available Free, Plus, or Pro plan, and complete its activation.

Next, open the official service documentation. Find the Service token panel and copy the service-scoped token. Regenerating this token revokes the previous active token, so token rotation must update every deployed environment that uses it.

The service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter; there is no unauthenticated mode. This implementation uses the Bearer header because query-string credentials can leak into access logs and monitoring systems.

The exact operation is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Before writing application code, make one minimal request from a trusted terminal:

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://example.com"}'

Never commit the real token. Put it in Symfony’s local environment file for development and inject the same variable through your production secret manager:

# .env.local
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN

Choose a deliberately small architecture

The dashboard has two HTTP actions: one displays a client’s scan history, and another queues a scan for a site already registered to that client. Symfony Messenger performs the external request away from the browser request, while Doctrine DBAL stores sites, scan states, normalized results, and generated tasks.

Using a site registry instead of accepting arbitrary URLs at the scan endpoint prevents one client from requesting scans for another client’s domain. It also keeps the feature aligned with the service’s intended public-HTTPS scope. For a real agency, domain ownership or written authorization should be confirmed when a site is added.

Install the first-party components in an existing Symfony application:

composer require symfony/http-client symfony/messenger \
  symfony/doctrine-messenger doctrine/doctrine-bundle \
  doctrine/doctrine-migrations-bundle symfony/uid twig

php bin/console doctrine:database:create
php bin/console make:migration
php bin/console doctrine:migrations:migrate

The relevant project structure is compact:

src/
  Controller/SecurityDashboardController.php
  Message/AnalyzeWebsite.php
  MessageHandler/AnalyzeWebsiteHandler.php
  Security/AnalyzerClient.php
  Security/AnalysisResult.php
templates/security/dashboard.html.twig
tests/Security/AnalyzerClientTest.php
config/packages/messenger.yaml
config/services.yaml

A migration should create a client_site table with id, client_slug, and url, plus a security_scan table containing id, site_id, status, nullable score, JSON columns for findings, tls, and tasks, a nullable failure_code, and timestamps. Add indexes on client_slug, site_id, and created_at. Keep scan rows immutable after completion except for operational annotations; history loses its meaning if old results are silently overwritten.

Build a defensive API boundary

The supplied contract promises a score, severity-grouped findings, TLS details, and recommendations. It does not justify assumptions about every nested finding or recommendation. Normalize those values at one boundary and reject structurally incomplete responses instead of spreading unchecked array access through controllers and templates.

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

final readonly class AnalysisResult
{
    public function __construct(
        public int|float $score,
        public array $findings,
        public array $tls,
        public array $recommendations,
    ) {}

    public static function fromArray(array $data): self
    {
        if (!isset($data['score']) || !is_numeric($data['score'])) {
            throw new \UnexpectedValueException('Missing numeric score');
        }

        foreach (['findings', 'tls', 'recommendations'] as $field) {
            if (!isset($data[$field]) || !is_array($data[$field])) {
                throw new \UnexpectedValueException("Invalid {$field}");
            }
        }

        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new \UnexpectedValueException('Findings must be grouped by severity');
            }
        }

        return new self(
            score: $data['score'] + 0,
            findings: $data['findings'],
            tls: $data['tls'],
            recommendations: $data['recommendations'],
        );
    }
}

The client uses bounded timeouts and a maximum of three attempts. It retries only rate limiting and likely transient gateway or server failures. Authentication errors, malformed requests, and other client errors fail immediately. The log contains status and attempt metadata, never the token, response body, or full request headers.

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

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

final class AnalyzerClient
{
    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 $websiteSecurityToken,
    ) {}

    public function analyze(string $url): AnalysisResult
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'auth_bearer' => $this->websiteSecurityToken,
                    'json' => ['url' => $url],
                    'timeout' => 20.0,
                    'max_duration' => 30.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    $data = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );

                    if (!is_array($data)) {
                        throw new \UnexpectedValueException('Response is not an object');
                    }

                    return AnalysisResult::fromArray($data);
                }

                $retryable = $status === 429 || in_array($status, [502, 503, 504], true);
                $this->logger->warning('Website analysis request failed', [
                    'status' => $status,
                    'attempt' => $attempt,
                    'retryable' => $retryable,
                ]);

                if (!$retryable || $attempt === 3) {
                    $code = match ($status) {
                        401, 403 => 'authentication',
                        429 => 'quota_or_rate_limit',
                        default => 'upstream_http',
                    };
                    throw new AnalyzerException($code, "Analyzer returned HTTP {$status}");
                }
            } catch (\JsonException|\UnexpectedValueException $e) {
                throw new AnalyzerException('invalid_response', $e->getMessage(), $e);
            } catch (\Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException('transport', 'Analyzer unavailable', $e);
                }
            }

            usleep((200 * (2 ** ($attempt - 1)) + random_int(0, 100)) * 1000);
        }

        throw new AnalyzerException('transport', 'Analyzer unavailable');
    }
}

final class AnalyzerException extends \RuntimeException
{
    public function __construct(
        public readonly string $failureCode,
        string $message,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Wire the environment value explicitly:

# config/services.yaml
services:
  App\Security\AnalyzerClient:
    arguments:
      $websiteSecurityToken: '%env(WEBSITE_SECURITY_TOKEN)%'

Queue scans and generate remediation work

The message contains only the scan identifier. The handler reloads the approved site, performs the analysis, and converts recommendations into stable task records. A recommendation may be a string or an object; the normalizer handles both without claiming undocumented fields are guaranteed.

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

final readonly class AnalyzeWebsite
{
    public function __construct(public string $scanId) {}
}

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

use App\Message\AnalyzeWebsite;
use App\Security\AnalyzerClient;
use App\Security\AnalyzerException;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class AnalyzeWebsiteHandler
{
    public function __construct(
        private Connection $db,
        private AnalyzerClient $analyzer,
    ) {}

    public function __invoke(AnalyzeWebsite $message): void
    {
        $row = $this->db->fetchAssociative(
            'SELECT s.id, cs.url FROM security_scan s
             JOIN client_site cs ON cs.id = s.site_id
             WHERE s.id = ? AND s.status = ?',
            [$message->scanId, 'queued']
        );

        if (!$row) {
            return; // Idempotent redelivery or deleted work.
        }

        $this->db->update('security_scan', ['status' => 'running'], ['id' => $row['id']]);

        try {
            $result = $this->analyzer->analyze($row['url']);
            $tasks = [];

            foreach ($result->recommendations as $index => $recommendation) {
                if (is_string($recommendation)) {
                    $title = trim($recommendation);
                    $detail = '';
                } elseif (is_array($recommendation)) {
                    $title = trim((string) ($recommendation['title'] ?? 'Review recommendation'));
                    $detail = trim((string) ($recommendation['description'] ?? ''));
                } else {
                    continue;
                }

                if ($title !== '') {
                    $tasks[] = [
                        'key' => 'recommendation-'.$index,
                        'title' => $title,
                        'detail' => $detail,
                        'status' => 'open',
                    ];
                }
            }

            $this->db->update('security_scan', [
                'status' => 'completed',
                'score' => $result->score,
                'findings' => json_encode($result->findings, JSON_THROW_ON_ERROR),
                'tls' => json_encode($result->tls, JSON_THROW_ON_ERROR),
                'tasks' => json_encode($tasks, JSON_THROW_ON_ERROR),
                'completed_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
            ], ['id' => $row['id']]);
        } catch (AnalyzerException $e) {
            $this->db->update('security_scan', [
                'status' => 'failed',
                'failure_code' => $e->failureCode,
                'completed_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
            ], ['id' => $row['id']]);
        }
    }
}

The controller must authorize access to the requested client before either query. The scan action looks up the site by both site ID and client slug, creates a queued row with a Symfony UID, dispatches the message, and redirects back to the dashboard. Protect the POST action with Symfony Security authorization and a CSRF token. Never trust a hidden URL field from the browser.

<?php
#[Route('/clients/{slug}/security', methods: ['GET'])]
public function dashboard(string $slug, Connection $db): Response
{
    $this->denyAccessUnlessGranted('VIEW_CLIENT', $slug);

    $scans = $db->fetchAllAssociative(
        'SELECT s.*, cs.url FROM security_scan s
         JOIN client_site cs ON cs.id = s.site_id
         WHERE cs.client_slug = ? ORDER BY s.created_at DESC',
        [$slug]
    );

    foreach ($scans as &$scan) {
        foreach (['findings', 'tls', 'tasks'] as $field) {
            $scan[$field] = json_decode($scan[$field] ?: '[]', true) ?: [];
        }
    }

    return $this->render('security/dashboard.html.twig', [
        'clientSlug' => $slug,
        'scans' => $scans,
    ]);
}

#[Route('/clients/{slug}/security/sites/{siteId}/scan', methods: ['POST'])]
public function scan(
    string $slug,
    int $siteId,
    Request $request,
    Connection $db,
    MessageBusInterface $bus,
): Response {
    $this->denyAccessUnlessGranted('EDIT_CLIENT', $slug);

    if (!$this->isCsrfTokenValid('scan-'.$siteId, $request->request->getString('_token'))) {
        throw $this->createAccessDeniedException();
    }

    $site = $db->fetchAssociative(
        'SELECT id FROM client_site WHERE id = ? AND client_slug = ?',
        [$siteId, $slug]
    );
    if (!$site) {
        throw $this->createNotFoundException();
    }

    $id = (string) new \Symfony\Component\Uid\UuidV7();
    $db->insert('security_scan', [
        'id' => $id,
        'site_id' => $siteId,
        'status' => 'queued',
        'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
    ]);

    $bus->dispatch(new \App\Message\AnalyzeWebsite($id));

    return $this->redirectToRoute('security_dashboard', ['slug' => $slug]);
}

Present findings without overstating them

The Twig view should lead with status, score, scan time, and the assessed URL. Below that, render findings under their returned severity groups, show TLS details as escaped labels and values, and list open remediation tasks with their descriptions. Twig auto-escaping must remain enabled.

Add a visible explanation: “This is a bounded, non-invasive review of public HTTPS and browser security posture. It is not a penetration test and does not prove that an application is free of vulnerabilities.” Failed scans should display a useful internal message derived from failure_code, such as “credentials need attention” or “service temporarily unavailable,” without exposing upstream response bodies.

Test the boundary and failure policy

MockHttpClient makes the test deterministic and prevents accidental network traffic. Test successful mapping, malformed JSON, incomplete fields, authentication failure without retry, and a transient response followed by success.

<?php
public function testMapsSuccessfulResponse(): void
{
    $http = new MockHttpClient(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]));

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

    self::assertSame(82, $result->score);
    self::assertArrayHasKey('medium', $result->findings);
}

public function testAuthenticationFailureIsNotRetried(): void
{
    $calls = 0;
    $http = new MockHttpClient(function () use (&$calls) {
        $calls++;
        return new MockResponse('{}', ['http_code' => 401]);
    });

    try {
        (new AnalyzerClient($http, new NullLogger(), 'bad-token'))
            ->analyze('https://example.com');
        self::fail('Expected AnalyzerException');
    } catch (AnalyzerException $e) {
        self::assertSame('authentication', $e->failureCode);
        self::assertSame(1, $calls);
    }
}

Operate it in production

Route the message to an asynchronous transport and run a supervised worker:

# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      async: '%env(MESSENGER_TRANSPORT_DSN)%'
    routing:
      App\Message\AnalyzeWebsite: async
APP_ENV=prod php bin/console doctrine:migrations:migrate --no-interaction
APP_ENV=prod php bin/console messenger:consume async \
  --time-limit=3600 --memory-limit=256M --no-interaction

Keep at least one worker under systemd, Supervisor, or the process manager provided by the hosting platform. Restart workers after deployment so they load new code. Alert on rising failed-scan counts, authentication failures, rate-limit responses, queue age, and request latency. Logs should carry the scan ID and client identifier, but not tokens or full API payloads.

Common failures are predictable: a revoked token produces authentication failures; exhausted plan capacity or rapid scheduling may produce rate limiting; a worker that is not running leaves rows queued; invalid upstream JSON becomes invalid_response; and unreachable services become transport. None should turn into an endless retry storm.

Final verification checklist

  • The token comes from the documentation page’s Service token panel and exists only in environment-backed configuration.
  • The application sends JSON containing url to the exact HTTPS POST endpoint with Bearer authentication.
  • Only authorized, pre-registered client sites can be queued.
  • Completed scans retain score, severity-grouped findings, TLS details, recommendations, and generated tasks.
  • Authentication and validation failures are not retried; transient failures use bounded backoff.
  • Tests run without network access, the Messenger worker is supervised, and sensitive payloads never enter logs.
  • The dashboard clearly states that the analysis is not a penetration test.

The strongest part of this design is not the score at the top of the page. It is the short path from a bounded external assessment to owned, visible work. A client can see progress over time, while the agency receives concrete tasks instead of another report destined to disappear into an inbox.

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.