Symfony Security Dashboard: Schedule Website Health Checks
A security dashboard earns trust only when it answers two questions clearly: what is wrong now, and when was the evidence last refreshed? A one-off scan cannot do that. Production systems need repeatable assessment, retained results, understandable failure states, and strict separation between customer data.
This tutorial builds that system with PHP 8.3, Symfony 7.4, PostgreSQL, Symfony HttpClient, and the Mihajlo Website Security Analyzer. The finished application stores authorized customer sites, reassesses them on schedule, preserves assessment history, and renders the latest website security posture through an authenticated dashboard.
The analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. It must not be described as a penetration test, vulnerability certification, or proof that a site is secure.
Get access and copy the service token
Complete the access setup before creating the Symfony client:
- Create an AI-tools account, or sign in if you already have one.
- Open the Website Security Analyzer service page, scroll to the plans, choose Free for an initial test or a larger plan for production use, and complete activation.
- Open the interactive API documentation. In the Service token panel, press Copy to copy the service-scoped token.
- Put the token in
.env.local. Never commit it. Regenerating a token revokes the previous active token, so update the deployed secret immediately after rotation.
MIHAJLO_API_TOKEN=YOUR_SERVICE_TOKEN
Verify the token and request contract directly before connecting it to Symfony:
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://example.com\"}"
A successful JSON response confirms that the plan is active, the token is valid, and the endpoint can be reached from the machine where the project will run.
Understand the API boundary first
The official contract is documented in the Website Security Analyzer API documentation. The integration uses exactly:
POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Authentication may use a Bearer token, an X-API-Token header, or a token query parameter. This implementation chooses the Bearer form because it keeps credentials out of URLs and access logs. The JSON request contains one field, url. The response is mapped into a score, severity-grouped findings, TLS details, and recommendations.
The remote response is untrusted input. Even when fields are documented, the application validates their types before anything reaches persistence or Twig.
Architecture and trade-offs
The browser never invokes the analyzer. A scheduled Symfony command performs the slow external work, while the controller reads a local snapshot. Customers therefore get predictable dashboard latency even when the upstream API is slow or temporarily unavailable.
- Analyzer client: owns authentication, timeouts, response validation, and bounded retries.
- Domain result: prevents arbitrary upstream JSON from leaking through the application.
- Repository: claims due sites with a database lease and stores snapshots plus history.
- Console command: reassesses sites outside request processing.
- Dashboard controller: enforces customer ownership before displaying a result.
Messenger would be reasonable at much larger throughput, but it is unnecessary here. A database lease and a small scheduled batch provide crash recovery and safe multi-instance execution without introducing another transport.
Create the Symfony project
Symfony 7.4 supports PHP 8.3. Doctrine DBAL 4 provides explicit SQL and transaction control without requiring ORM entities for this small persistence model.
composer create-project symfony/skeleton:"7.4.*" security-dashboard
cd security-dashboard
composer require symfony/http-client:^7.4 symfony/console:^7.4 \
symfony/twig-bundle:^7.4 symfony/security-bundle:^7.4 \
doctrine/doctrine-bundle:^2.13 doctrine/dbal:^4.0
composer require --dev symfony/test-pack
The relevant project structure is deliberately compact:
src/
Command/ReassessWebsitesCommand.php
Controller/SecurityDashboardController.php
Security/AnalyzerClient.php
Security/AnalyzerException.php
Security/AssessmentResult.php
Security/AssessmentRepository.php
templates/security/dashboard.html.twig
tests/Security/AnalyzerClientTest.php
migrations/Version20260811000000.php
config/services.yaml
Configure secrets and dependency injection
Commit the endpoint as ordinary configuration, but inject the token through the environment. Put the placeholder in .env; use .env.local, a deployment secret, or a secret manager for the actual value.
MIHAJLO_ANALYZER_ENDPOINT=https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
MIHAJLO_API_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_URL=postgresql://app:[email protected]:5432/security_dashboard
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Security\AnalyzerClient:
arguments:
$endpoint: '%env(string:MIHAJLO_ANALYZER_ENDPOINT)%'
$token: '%env(string:MIHAJLO_API_TOKEN)%'
Never log the token, authorization header, or complete upstream response. URLs can also contain sensitive paths or query strings, so operational logs should normally identify only the site ID and hostname.
Build a defensive domain mapper
The mapper requires all four result areas while avoiding assumptions about individual finding objects. That preserves forward compatibility inside each severity group without accepting a structurally unrelated response.
<?php
// src/Security/AssessmentResult.php
namespace App\Security;
final readonly class AssessmentResult
{
public function __construct(
public int|float $score,
public array $findingsBySeverity,
public array $tlsDetails,
public array $recommendations,
) {}
public static function fromPayload(array $data): self
{
if (!isset($data['score']) || !is_int($data['score']) && !is_float($data['score'])) {
throw new \UnexpectedValueException('Analyzer score is missing or invalid.');
}
foreach (['findings', 'tls', 'recommendations'] as $field) {
if (!isset($data[$field]) || !is_array($data[$field])) {
throw new \UnexpectedValueException("Analyzer field {$field} is invalid.");
}
}
foreach ($data['findings'] as $severity => $findings) {
if (!is_string($severity) || !is_array($findings)) {
throw new \UnexpectedValueException('Findings are not grouped by severity.');
}
}
return new self(
$data['score'],
$data['findings'],
$data['tls'],
array_values($data['recommendations']),
);
}
}
Twig escapes displayed strings by default. Keep that protection enabled: analyzer output must never be marked safe or rendered as raw HTML.
Implement the resilient HTTP client
External calls receive bounded connection and overall response timeouts. The client retries transport failures, HTTP 429, and server errors at most twice after the first attempt. Authentication, validation, and other client errors fail immediately because another identical request will not repair them.
<?php
// src/Security/AnalyzerException.php
namespace App\Security;
final class AnalyzerException extends \RuntimeException
{
public function __construct(
public readonly string $category,
public readonly bool $retryable,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/Security/AnalyzerClient.php
namespace App\Security;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class AnalyzerClient
{
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $endpoint,
private string $token,
) {}
public function analyze(string $url): AssessmentResult
{
$host = parse_url($url, PHP_URL_HOST) ?: 'invalid-host';
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', $this->endpoint, [
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new \UnexpectedValueException('Expected a JSON object.');
}
return AssessmentResult::fromPayload($payload);
} catch (\JsonException|\UnexpectedValueException $e) {
throw new AnalyzerException(
'response_contract',
false,
'The analyzer returned an invalid response.',
$e
);
}
}
$retryable = $status === 429 || $status >= 500;
$category = match (true) {
$status === 401 || $status === 403 => 'authentication',
$status === 400 || $status === 422 => 'invalid_target',
$status === 429 => 'rate_limited',
$status >= 500 => 'upstream',
default => 'http_error',
};
if (!$retryable || $attempt === 3) {
throw new AnalyzerException(
$category,
$retryable,
"Analyzer request failed with HTTP {$status}."
);
}
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new AnalyzerException(
'transport',
true,
'Analyzer transport failed.',
$e
);
}
}
$this->logger->warning('Website analysis will be retried.', [
'host' => $host,
'attempt' => $attempt,
]);
sleep(2 ** ($attempt - 1));
}
throw new \LogicException('Retry loop terminated unexpectedly.');
}
}
A production variant may honor a numeric Retry-After header, but it should clamp the delay to a configured maximum. Never let an upstream header suspend a worker indefinitely.
Store snapshots, history, and scheduling state
The following PostgreSQL migration separates the current dashboard snapshot from immutable assessment history. locked_until is a renewable claim, not a permanent lock; a crashed process becomes eligible again after the lease expires.
CREATE TABLE monitored_site (
id BIGSERIAL PRIMARY KEY,
client_id VARCHAR(180) NOT NULL,
url TEXT NOT NULL,
interval_minutes INTEGER NOT NULL CHECK (interval_minutes >= 15),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
next_assessment_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_until TIMESTAMPTZ NULL,
last_status VARCHAR(32) NULL,
last_error_code VARCHAR(64) NULL,
latest_score DOUBLE PRECISION NULL,
latest_findings JSONB NULL,
latest_tls JSONB NULL,
latest_recommendations JSONB NULL,
latest_assessed_at TIMESTAMPTZ NULL,
UNIQUE (client_id, url)
);
CREATE INDEX monitored_site_due_idx
ON monitored_site (next_assessment_at)
WHERE enabled = TRUE;
CREATE TABLE security_assessment (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL REFERENCES monitored_site(id) ON DELETE CASCADE,
status VARCHAR(32) NOT NULL,
score DOUBLE PRECISION NULL,
findings JSONB NULL,
tls JSONB NULL,
recommendations JSONB NULL,
error_code VARCHAR(64) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
The repository claims one record at a time. That keeps the lease valid even when an earlier API request consumes its full timeout budget.
<?php
// Selected methods from src/Security/AssessmentRepository.php
namespace App\Security;
use Doctrine\DBAL\Connection;
final readonly class AssessmentRepository
{
public function __construct(private Connection $db) {}
public function claimOne(): ?array
{
$row = $this->db->fetchAssociative(<<<'SQL'
WITH candidate AS (
SELECT id FROM monitored_site
WHERE enabled = TRUE
AND next_assessment_at <= CURRENT_TIMESTAMP
AND (locked_until IS NULL OR locked_until < CURRENT_TIMESTAMP)
ORDER BY next_assessment_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE monitored_site AS site
SET locked_until = CURRENT_TIMESTAMP + INTERVAL '2 minutes'
FROM candidate
WHERE site.id = candidate.id
RETURNING site.*
SQL);
return $row ?: null;
}
public function saveSuccess(array $site, AssessmentResult $result): void
{
$values = [
'id' => $site['id'],
'score' => $result->score,
'findings' => json_encode($result->findingsBySeverity, JSON_THROW_ON_ERROR),
'tls' => json_encode($result->tlsDetails, JSON_THROW_ON_ERROR),
'recommendations' => json_encode($result->recommendations, JSON_THROW_ON_ERROR),
];
$this->db->transactional(function (Connection $db) use ($values): void {
$db->executeStatement(<<<'SQL'
INSERT INTO security_assessment
(site_id, status, score, findings, tls, recommendations)
VALUES (:id, 'success', :score, :findings::jsonb, :tls::jsonb, :recommendations::jsonb)
SQL, $values);
$db->executeStatement(<<<'SQL'
UPDATE monitored_site
SET last_status = 'success', last_error_code = NULL,
latest_score = :score, latest_findings = :findings::jsonb,
latest_tls = :tls::jsonb, latest_recommendations = :recommendations::jsonb,
latest_assessed_at = CURRENT_TIMESTAMP, locked_until = NULL,
next_assessment_at =
CURRENT_TIMESTAMP + interval_minutes * INTERVAL '1 minute'
WHERE id = :id
SQL, $values);
});
}
public function saveFailure(array $site, AnalyzerException $e): void
{
$delay = $e->retryable ? 15 : (int) $site['interval_minutes'];
$this->db->executeStatement(<<<'SQL'
UPDATE monitored_site
SET last_status = 'failed', last_error_code = :error,
locked_until = NULL,
next_assessment_at = CURRENT_TIMESTAMP + :delay * INTERVAL '1 minute'
WHERE id = :id
SQL, ['id' => $site['id'], 'error' => $e->category, 'delay' => $delay]);
}
public function dashboard(int $siteId, string $clientId): ?array
{
$row = $this->db->fetchAssociative(
'SELECT * FROM monitored_site WHERE id = :id AND client_id = :client',
['id' => $siteId, 'client' => $clientId]
);
if (!$row) {
return null;
}
foreach (['latest_findings', 'latest_tls', 'latest_recommendations'] as $field) {
$row[$field] = $row[$field] === null
? []
: json_decode($row[$field], true, 512, JSON_THROW_ON_ERROR);
}
return $row;
}
}
Run reassessment as a scheduled command
<?php
// src/Command/ReassessWebsitesCommand.php
namespace App\Command;
use App\Security\AnalyzerClient;
use App\Security\AnalyzerException;
use App\Security\AssessmentRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:security:reassess')]
final class ReassessWebsitesCommand extends Command
{
public function __construct(
private readonly AssessmentRepository $repository,
private readonly AnalyzerClient $analyzer,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum sites', '20');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$limit = max(1, min(100, (int) $input->getOption('limit')));
for ($processed = 0; $processed < $limit; $processed++) {
$site = $this->repository->claimOne();
if ($site === null) {
break;
}
try {
$result = $this->analyzer->analyze($site['url']);
$this->repository->saveSuccess($site, $result);
} catch (AnalyzerException $e) {
$this->repository->saveFailure($site, $e);
$this->logger->error('Website assessment failed.', [
'site_id' => $site['id'],
'category' => $e->category,
'retryable' => $e->retryable,
]);
}
}
return Command::SUCCESS;
}
}
Run the command every five minutes; each row’s next_assessment_at controls its actual cadence. On a traditional host:
*/5 * * * * cd /srv/security-dashboard && php bin/console app:security:reassess --limit=20 --env=prod
Expose an ownership-safe customer dashboard
The database query includes both site ID and authenticated customer ID. Returning 404 for a mismatch avoids revealing whether another customer’s site exists.
<?php
// src/Controller/SecurityDashboardController.php
namespace App\Controller;
use App\Security\AssessmentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CLIENT')]
final class SecurityDashboardController extends AbstractController
{
#[Route('/security-health/{siteId}', name: 'security_dashboard', methods: ['GET'])]
public function __invoke(int $siteId, AssessmentRepository $repository): Response
{
$user = $this->getUser();
$site = $repository->dashboard($siteId, $user->getUserIdentifier());
if ($site === null) {
throw $this->createNotFoundException();
}
return $this->render('security/dashboard.html.twig', ['site' => $site]);
}
}
<h2>Website security health</h2>
<p>{{ site.url }}</p>
<p>Status: {{ site.last_status ?? 'Not assessed' }}</p>
{% if site.latest_assessed_at %}
<p>Last assessed: {{ site.latest_assessed_at }}</p>
<p>Score: {{ site.latest_score }}</p>
<h3>Findings</h3>
{% for severity, findings in site.latest_findings %}
<h3>{{ severity|title }}</h3>
<pre>{{ findings|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
{% endfor %}
<h3>TLS details</h3>
<pre>{{ site.latest_tls|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
<h3>Recommendations</h3>
<pre>{{ site.latest_recommendations|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
{% endif %}
<p>This is a bounded, non-invasive review of public HTTPS and browser
security posture. It is not a penetration test or security certification.</p>
Test without contacting the service
MockHttpClient makes the boundary deterministic. Cover success, malformed JSON, authentication failure without retry, rate limiting with bounded retries, and transport failure. Repository claim tests should run against PostgreSQL because SQLite cannot reproduce SKIP LOCKED or PostgreSQL JSON behavior.
<?php
// tests/Security/AnalyzerClientTest.php
namespace App\Tests\Security;
use App\Security\AnalyzerClient;
use App\Security\AnalyzerException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class AnalyzerClientTest extends TestCase
{
public function testMapsAValidAssessment(): void
{
$http = new MockHttpClient(new MockResponse(json_encode([
'score' => 82,
'findings' => ['high' => [], 'medium' => [['name' => 'Example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the medium-severity finding.'],
], JSON_THROW_ON_ERROR)));
$client = new AnalyzerClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'test-token'
);
$result = $client->analyze('https://example.com');
self::assertSame(82, $result->score);
self::assertArrayHasKey('medium', $result->findingsBySeverity);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$calls = 0;
$http = new MockHttpClient(function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('{}', ['http_code' => 401]);
});
$client = new AnalyzerClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'bad-token'
);
try {
$client->analyze('https://example.com');
self::fail('Expected AnalyzerException.');
} catch (AnalyzerException $e) {
self::assertSame('authentication', $e->category);
self::assertFalse($e->retryable);
self::assertSame(1, $calls);
}
}
}
Production safeguards and common failures
Accept only normalized https URLs for sites a customer is authorized to monitor. Domain ownership verification is a sensible enrollment requirement. Reject embedded credentials, fragments, malformed hosts, and unsupported schemes before persistence. Although your server sends the URL to a bounded remote analyzer rather than fetching it directly, enrollment controls still prevent abuse.
- 401 or 403: verify the injected secret and authentication form; do not retry blindly.
- 400 or 422: mark the target invalid and require correction.
- 429: retain the last successful snapshot, expose stale timing, and schedule a delayed retry.
- 5xx or timeout: use bounded backoff and preserve the structured failure category.
- Contract failure: alert operators; never substitute fabricated defaults for missing security data.
Monitor success and failure counts, assessment age, latency, retry count, and the number of overdue sites. Alert on growing backlog and repeated authentication failures. Keep historical results according to a declared retention policy, and restrict logs and database access because findings may disclose defensive weaknesses.
Final verification checklist
- Run the migration and confirm the application can reach PostgreSQL.
- Inject a valid service token through the production secret mechanism.
- Enroll an authorized public HTTPS site with a customer ID and assessment interval.
- Run
php bin/console app:security:reassess --limit=1 --env=prod. - Confirm a successful history row and updated dashboard snapshot.
- Sign in as the owning customer and verify score, findings, TLS details, recommendations, and assessment time.
- Verify another customer receives 404 for the same site ID.
- Simulate 401, 429, malformed JSON, and timeout responses in tests.
- Enable the scheduler and alert when assessments become overdue.
The strongest feature of this dashboard is not its score. It is the disciplined chain behind that score: an authorized target, a bounded assessment, a validated contract, an auditable snapshot, and a known refresh time. That chain turns a remote security signal into something customers can understand—and operators can trust.