Tutorials

Symfony Security Dashboard: Client Site Analysis and Actionable Remediation

Symfony Security Dashboard: Client Site Analysis and Actionable Remediation

A security scan becomes valuable only when somebody can understand what changed and what to fix next. For a small agency managing several client sites, a one-off API response is not enough: the useful product is a durable history of scans, clearly grouped findings, TLS context, and remediation tasks that can be checked off.

This tutorial builds that product with PHP 8.3 and Symfony. Scans run outside the request cycle through Messenger, results are validated at the API boundary, and every outcome becomes an explicit success or failure state. The analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. It must not be presented to clients as a penetration test, vulnerability assessment, or guarantee of security.

Get access and copy the 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.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy the service-scoped token shown there.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps the credential out of URLs, browser history, and routine access logs. Regenerating the service token revokes the previously active token, so token rotation must update every deployed environment that uses it.

Confirm the endpoint before writing application code

The exact operation is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON request contains url:

curl --request POST \
  '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"}'

Use a public site that you are authorized to manage. Do not paste the returned payload into source control: findings can reveal details about a client’s security posture.

Place the credential in uncommitted local configuration. In production, inject the same variable through the hosting platform’s secret manager:

# .env.local
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_URL="postgresql://app:[email protected]:5432/security_dashboard"
MESSENGER_TRANSPORT_DSN="doctrine://default?auto_setup=false&queue_name=security_analysis"

Architecture that suits a small agency

The browser submits a client name and HTTPS URL. The controller creates a queued scan record and dispatches its identifier through Messenger. A worker calls the analyzer, validates the response, and atomically stores the score, severity-grouped findings, TLS details, recommendations, and locally generated remediation tasks.

This asynchronous boundary matters. Remote analysis and controlled retries should not keep a browser request open. The trade-off is operational: at least one Messenger worker must run, and the dashboard shows short-lived queued and running states.

The project’s important files are:

src/
  Analyzer/Analysis.php
  Analyzer/AnalyzerException.php
  Analyzer/WebsiteSecurityAnalyzer.php
  Controller/SecurityDashboardController.php
  Message/AnalyzeWebsite.php
  MessageHandler/AnalyzeWebsiteHandler.php
  Repository/SecurityScanRepository.php
migrations/Version20250101000000.php
templates/security/index.html.twig
tests/Analyzer/WebsiteSecurityAnalyzerTest.php
config/packages/messenger.yaml
config/services.yaml

Create the Symfony application

composer create-project symfony/skeleton agency-security-dashboard
cd agency-security-dashboard
composer require symfony/http-client symfony/twig-bundle symfony/messenger \
  symfony/doctrine-messenger symfony/security-csrf symfony/monolog-bundle \
  doctrine/doctrine-bundle doctrine/dbal doctrine/doctrine-migrations-bundle
composer require --dev symfony/test-pack

Create two tables through a Doctrine migration: security_scan holds the immutable analyzer result and lifecycle state; remediation_task holds checkable work derived from recommendations. Use string identifiers generated with bin2hex(random_bytes(16)), JSON columns for the upstream structures, timestamps, and an index on security_scan.status. Add a foreign key from each task to its scan with cascade deletion.

This schema deliberately stores the validated response rather than assuming a permanent shape for individual finding or TLS attributes. The API contract guarantees the top-level concepts; rendering nested values defensively protects the dashboard from undocumented structural changes.

Configure dependency injection and the queue

# config/services.yaml
parameters: {}

services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      $analyzerToken: '%env(WEBSITE_SECURITY_TOKEN)%'

  App\:
    resource: '../src/'
    exclude:
      - '../src/Kernel.php'

# config/packages/messenger.yaml
framework:
  messenger:
    failure_transport: failed
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 0
      failed: 'doctrine://default?queue_name=failed'
    routing:
      App\Message\AnalyzeWebsite: async

The HTTP boundary performs its own short retries, so Messenger retries are disabled to avoid multiplying calls unexpectedly. Every exhausted failure is written to the scan record. The separate failed transport remains useful for failures outside normal handler control, such as a terminated worker.

Validate the analyzer response at the boundary

<?php
// src/Analyzer/Analysis.php
namespace App\Analyzer;

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

    public static function fromPayload(array $payload): self
    {
        $score = $payload['score'] ?? null;
        $findings = $payload['findings'] ?? null;
        $tls = $payload['tls'] ?? null;
        $recommendations = $payload['recommendations'] ?? null;

        if ((!is_int($score) && !is_float($score)) || !is_finite((float) $score)) {
            throw new AnalyzerException('invalid_response', 'Analyzer score is invalid.');
        }

        if (!is_array($findings) || !is_array($tls) || !is_array($recommendations)) {
            throw new AnalyzerException('invalid_response', 'Analyzer sections are invalid.');
        }

        foreach ($findings as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new AnalyzerException(
                    'invalid_response',
                    'Findings are not grouped by severity.'
                );
            }
        }

        return new self((float) $score, $findings, $tls, $recommendations);
    }
}

// src/Analyzer/AnalyzerException.php
namespace App\Analyzer;

final class AnalyzerException extends \RuntimeException
{
    public function __construct(
        public readonly string $failureCode,
        string $message,
    ) {
        parent::__construct($message);
    }
}

Notice what this mapper does not do: it does not invent nested response fields. Findings remain grouped by the severity keys supplied by the service, while TLS data and recommendations remain validated JSON structures.

Build a bounded, failure-aware HTTP client

<?php
// src/Analyzer/WebsiteSecurityAnalyzer.php
namespace App\Analyzer;

use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

    public function __construct(
        private HttpClientInterface $http,
        private string $analyzerToken,
    ) {}

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

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    try {
                        return Analysis::fromPayload($response->toArray(false));
                    } catch (DecodingExceptionInterface $e) {
                        throw new AnalyzerException(
                            'invalid_response',
                            'Analyzer returned invalid JSON.'
                        );
                    }
                }

                if ($status === 401 || $status === 403) {
                    throw new AnalyzerException(
                        'authentication',
                        'Analyzer authentication was rejected.'
                    );
                }

                if ($status === 400 || $status === 422) {
                    throw new AnalyzerException(
                        'validation',
                        'Analyzer rejected the submitted URL.'
                    );
                }

                $retryable = $status === 429 || $status >= 500;
                if (!$retryable) {
                    throw new AnalyzerException(
                        'upstream_response',
                        'Analyzer returned an unexpected response.'
                    );
                }

                if ($attempt === 3) {
                    $code = $status === 429 ? 'rate_limited' : 'upstream_unavailable';
                    throw new AnalyzerException($code, 'Analyzer is temporarily unavailable.');
                }

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

                usleep($delayMs * 1000);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'transport',
                        'Could not reach the analyzer.'
                    );
                }

                usleep(250 * (2 ** ($attempt - 1)) * 1000);
            }
        }

        throw new AnalyzerException('internal', 'Analysis did not complete.');
    }
}

Only transport failures, HTTP 429 responses, and server-side failures are retried. Authentication and validation failures need intervention, not repetition. The delays are bounded, as are connection inactivity and total request duration. Response bodies and credentials never enter exception messages.

Persist history and create remediation work

The repository should expose five focused operations: queue(), markRunning(), complete(), fail(), and history(). In complete(), use Connection::transactional() to update the scan and insert one task per recommendation.

A recommendation may be a string or a structured JSON value. Preserve its complete value in the scan. For the task summary, use the string directly; otherwise serialize the structure with JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR. This is less decorative than guessing at undocumented properties, but it is reliable.

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

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

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

use App\Analyzer\AnalyzerException;
use App\Analyzer\WebsiteSecurityAnalyzer;
use App\Message\AnalyzeWebsite;
use App\Repository\SecurityScanRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class AnalyzeWebsiteHandler
{
    public function __construct(
        private WebsiteSecurityAnalyzer $analyzer,
        private SecurityScanRepository $scans,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(AnalyzeWebsite $message): void
    {
        $this->scans->markRunning($message->scanId);

        try {
            $result = $this->analyzer->analyze($message->url);
            $this->scans->complete($message->scanId, $result);

            $this->logger->info('Security analysis completed.', [
                'scan_id' => $message->scanId,
                'score' => $result->score,
            ]);
        } catch (AnalyzerException $e) {
            $this->scans->fail(
                $message->scanId,
                $e->failureCode,
                $e->getMessage()
            );

            $this->logger->warning('Security analysis failed.', [
                'scan_id' => $message->scanId,
                'failure_code' => $e->failureCode,
            ]);
        } catch (\Throwable $e) {
            $this->scans->fail(
                $message->scanId,
                'internal',
                'An internal processing error occurred.'
            );

            $this->logger->error('Security analysis crashed.', [
                'scan_id' => $message->scanId,
                'exception_class' => $e::class,
            ]);
        }
    }
}

Keep task completion local to the agency application. A CSRF-protected POST route can update remediation_task.completed_at; it should never trigger another external analysis.

Controller and dashboard behavior

The create route must reject malformed input before dispatch. Require a sensible client label, an https scheme, a hostname, and no embedded username, password, query string, or fragment. Reject private or reserved IP literals. Store a normalized URL so secrets cannot accidentally appear in logs.

<?php
// Core create action in SecurityDashboardController
#[Route('/security/scans', name: 'security_scan_create', methods: ['POST'])]
public function create(
    Request $request,
    SecurityScanRepository $scans,
    MessageBusInterface $bus,
): Response {
    if (!$this->isCsrfTokenValid('create-scan', $request->request->getString('_token'))) {
        throw $this->createAccessDeniedException();
    }

    $client = trim($request->request->getString('client'));
    $rawUrl = trim($request->request->getString('url'));
    $parts = parse_url($rawUrl);

    $valid = strlen($client) >= 2
        && strlen($client) <= 120
        && filter_var($rawUrl, FILTER_VALIDATE_URL)
        && is_array($parts)
        && ($parts['scheme'] ?? null) === 'https'
        && isset($parts['host'])
        && !isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment']);

    if (!$valid) {
        $this->addFlash('error', 'Enter a public HTTPS URL and a valid client name.');
        return $this->redirectToRoute('security_dashboard');
    }

    $host = strtolower($parts['host']);
    if (filter_var($host, FILTER_VALIDATE_IP)
        && !filter_var(
            $host,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        )) {
        $this->addFlash('error', 'Private and reserved addresses are not allowed.');
        return $this->redirectToRoute('security_dashboard');
    }

    $id = $scans->queue($client, $rawUrl);
    $bus->dispatch(new AnalyzeWebsite($id, $rawUrl));

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

The GET dashboard should order scans newest first. Each card displays the client, URL, lifecycle state, score when available, findings under their returned severity headings, TLS details, and remediation tasks. Render unknown nested values through Twig’s json_encode filter rather than interpolating them as trusted HTML. Keep auto-escaping enabled.

Do not expose these routes publicly. Put them behind the agency’s existing Symfony authentication and an access_control rule, enforce HTTPS, and scope queries by agency or authenticated user if the application is multi-tenant. CSRF protection complements authentication; it does not replace it.

Automated boundary tests with MockHttpClient

<?php
namespace App\Tests\Analyzer;

use App\Analyzer\AnalyzerException;
use App\Analyzer\WebsiteSecurityAnalyzer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteSecurityAnalyzerTest extends TestCase
{
    public function testMapsAValidResponse(): void
    {
        $client = new MockHttpClient([
            new MockResponse(json_encode([
                'score' => 82,
                'findings' => ['high' => [], 'medium' => [['check' => 'header']]],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported header configuration.'],
            ], JSON_THROW_ON_ERROR), ['http_code' => 200]),
        ]);

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

        self::assertSame(82.0, $result->score);
        self::assertArrayHasKey('medium', $result->findings);
        self::assertSame(1, $client->getRequestsCount());
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $client = new MockHttpClient([
            new MockResponse('', ['http_code' => 401]),
        ]);

        try {
            (new WebsiteSecurityAnalyzer($client, 'invalid'))
                ->analyze('https://example.com');
            self::fail('Expected authentication failure.');
        } catch (AnalyzerException $e) {
            self::assertSame('authentication', $e->failureCode);
            self::assertSame(1, $client->getRequestsCount());
        }
    }
}

Add repository integration tests for transactional completion and controller tests for CSRF, invalid URLs, authorization, and successful message dispatch. Never place a real token in fixtures: MockHttpClient makes external traffic unnecessary and keeps the test suite deterministic.

Deployment and operations

php bin/console doctrine:migrations:migrate --no-interaction
php bin/console messenger:setup-transports
php bin/phpunit
php bin/console cache:clear --env=prod
php bin/console messenger:consume async \
  --time-limit=3600 --memory-limit=128M --no-interaction

Run the consumer under systemd, Supervisor, or the platform’s managed worker facility, and restart it after deployments so it loads new code. Deploy migrations before starting the new worker version. Configure graceful termination and keep more than one worker only when the subscribed plan and expected scan volume permit the resulting concurrency.

Log scan identifiers, lifecycle transitions, final failure codes, status classes, and duration. Do not log tokens, authorization headers, full upstream bodies, or URLs containing sensitive parameters. Useful operational signals include queue age, scans stuck in running, rates of authentication, rate_limited, and invalid_response failures, plus worker restarts.

Common production failures

  • Every scan reports authentication failure: verify plan activation and the deployed secret. If the token was regenerated, the previous value is revoked.
  • Scans remain queued: confirm the Messenger worker is running against the same database and transport configuration as the web process.
  • Rate limits recur: reduce worker concurrency or scan frequency. Do not increase retries indefinitely.
  • The API succeeds but mapping fails: retain the structured invalid_response state and compare the official documentation with the boundary mapper. Do not silently coerce missing sections.
  • History exists but tasks do not: verify that result storage and task insertion share one transaction and that structured recommendations serialize successfully.

Final verification checklist

  • The token comes from environment-backed configuration and never appears in source, fixtures, or logs.
  • A valid submission returns immediately with a queued history entry.
  • The worker changes that entry to running, then completed or a specific failure state.
  • Completed scans show score, severity-grouped findings, TLS details, and recommendations.
  • Recommendations produce durable, checkable remediation tasks.
  • Authentication and validation failures are not retried; rate and server failures use bounded backoff.
  • Dashboard and task routes require authentication, authorization, HTTPS, and CSRF protection.
  • Tests pass without contacting the live service.

The result is more than a wrapper around an endpoint. It is a modest operational system: client history makes change visible, defensive mapping keeps upstream data honest, and remediation tasks turn analysis into accountable work. That is the difference between displaying security information and building a dashboard an agency can actually use.

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.