Tutorials

Symfony Security Monitoring: Email Small Businesses When Website Scores Drop

Symfony Security Monitoring: Email Small Businesses When Website Scores Drop

A small business website can keep serving pages while its security posture quietly gets worse. A certificate change, a removed browser header, or a deployment mistake may not cause visible downtime, yet it can still lower the site’s security score.

This tutorial builds a production-oriented Symfony application that checks one public website every week, remembers the previous score, and emails the owner only when the new score is lower. The integration uses bounded, non-invasive analysis of public HTTPS and browser security posture. It is a monitoring signal, not a penetration test, vulnerability assessment, or substitute for authorized security testing.

Get access and copy the service token

Complete the service onboarding before writing integration code:

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Website Security Analyzer service page.
  3. Choose an 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.

Regenerating that token revokes the previously active token. Coordinate rotation with deployment so the application receives the replacement before, or immediately after, regeneration.

The API accepts a Bearer token, an X-API-Token header, or a token query parameter. This project uses Bearer authentication because headers are less likely than query strings to appear in proxy and access logs.

Confirm the endpoint before building the feature

The exact operation is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON request contains url. Test the token with a public HTTPS address that you control:

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

A successful response contains a score, findings grouped by severity, TLS details, and recommendations. We will validate those values at the application boundary instead of assuming that every nested finding has an undocumented shape.

Install the first-party Symfony components used by the project:

composer require symfony/http-client symfony/mailer symfony/cache symfony/lock
composer require --dev symfony/test-pack

Put credentials and deployment-specific addresses in .env.local for local development. Do not commit this file. In production, inject the same variables through the hosting platform or secret manager.

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_WEBSITE_URL=https://www.example.com
[email protected]
[email protected]

MAILER_DSN=smtp://USERNAME:[email protected]:587
LOCK_DSN=flock

A deliberately small architecture

A weekly check does not need a message broker merely to look sophisticated. A Symfony console command works well with cron or a platform scheduler, while a lock prevents overlapping executions.

  • WebsiteSecurityAnalyzer owns the remote HTTP boundary and retry policy.
  • SecuritySnapshot maps and validates the domain response.
  • SecurityMonitor compares scores, sends email, and persists the new baseline.
  • CheckWebsiteSecurityCommand supplies locking, exit status, and operator output.

The example stores state in a Symfony cache pool. A filesystem pool and flock are appropriate for one persistent server. Multiple application replicas require shared cache and lock backends; otherwise two hosts could compare different baselines and send duplicate alerts.

The relevant project structure is:

src/
  Command/CheckWebsiteSecurityCommand.php
  Security/AnalyzerException.php
  Security/SecurityMonitor.php
  Security/SecuritySnapshot.php
  Security/WebsiteAnalyzer.php
  Security/WebsiteSecurityAnalyzer.php
tests/
  Security/WebsiteSecurityAnalyzerTest.php
  Security/SecurityMonitorTest.php

Configure Symfony’s infrastructure

Create a scoped client so the token, base URL, and time limits remain outside application logic. The inactivity timeout and total duration prevent a weekly process from hanging indefinitely.

# config/packages/security_monitor.yaml
framework:
  http_client:
    scoped_clients:
      security_analyzer.client:
        base_uri: 'https://ai.mihajlo.mk/api/website-security-analyzer-api'
        headers:
          Authorization: 'Bearer %env(SECURITY_ANALYZER_TOKEN)%'
        timeout: 10
        max_duration: 20

  cache:
    pools:
      security_monitor.cache:
        adapter: cache.adapter.filesystem
        default_lifetime: 0

  lock: '%env(LOCK_DSN)%'
  mailer:
    dsn: '%env(MAILER_DSN)%'

Bind the named services and environment values through dependency injection:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      $securityAnalyzerClient: '@security_analyzer.client'
      $securityMonitorCache: '@security_monitor.cache'
      $websiteUrl: '%env(SECURITY_WEBSITE_URL)%'
      $ownerEmail: '%env(SECURITY_OWNER_EMAIL)%'
      $fromEmail: '%env(SECURITY_FROM_EMAIL)%'

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

  App\Security\WebsiteAnalyzer:
    alias: App\Security\WebsiteSecurityAnalyzer

Map the response at the API boundary

The DTO accepts numeric scores but does not invent a score range. It verifies the four promised response areas and preserves nested data whose exact shape may evolve.

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

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

    public static function fromArray(array $data): self
    {
        $score = $data['score'] ?? null;

        if ((!is_int($score) && !is_float($score))
            || !is_finite((float) $score)
            || !is_array($data['findings'] ?? null)
            || !is_array($data['tls'] ?? null)
            || !is_array($data['recommendations'] ?? null)
        ) {
            throw new AnalyzerException('The analyzer returned an invalid response.');
        }

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

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

Define a narrow interface so the monitoring policy can be tested without making HTTP calls:

<?php
namespace App\Security;

interface WebsiteAnalyzer
{
    public function analyze(string $url): SecuritySnapshot;
}

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

Build a bounded, selective HTTP client

The client retries only transport failures, rate limiting, and a small set of transient gateway failures. Authentication and validation errors fail immediately. Three total attempts with capped backoff are enough for a weekly task without turning one check into an uncontrolled quota consumer.

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

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

final class WebsiteSecurityAnalyzer implements WebsiteAnalyzer
{
    public function __construct(
        private HttpClientInterface $securityAnalyzerClient,
        private LoggerInterface $logger,
        private ?\Closure $sleep = null,
    ) {}

    public function analyze(string $url): SecuritySnapshot
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->securityAnalyzerClient->request(
                    'POST',
                    '/v1/analyze-website',
                    ['json' => ['url' => $url]],
                );
                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'The analyzer could not be reached.',
                        true,
                        $e,
                    );
                }

                $this->pause($attempt, null);
                continue;
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = $response->toArray(false);
                } catch (DecodingExceptionInterface $e) {
                    throw new AnalyzerException(
                        'The analyzer returned invalid JSON.',
                        false,
                        $e,
                    );
                }

                return SecuritySnapshot::fromArray($payload);
            }

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

            if ($status === 400 || $status === 422) {
                throw new AnalyzerException('The website request was rejected.');
            }

            $retryable = $status === 429
                || in_array($status, [502, 503, 504], true);

            if ($retryable && $attempt < 3) {
                $headers = $response->getHeaders(false);
                $retryAfter = $headers['retry-after'][0] ?? null;

                $this->logger->warning('Security analysis will be retried.', [
                    'status' => $status,
                    'attempt' => $attempt,
                ]);

                $this->pause($attempt, $retryAfter);
                continue;
            }

            throw new AnalyzerException(
                'Analyzer request failed with HTTP '.$status.'.',
                $retryable || $status >= 500,
            );
        }

        throw new AnalyzerException('Analyzer attempts were exhausted.', true);
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = is_string($retryAfter) && ctype_digit($retryAfter)
            ? min(30000, (int) $retryAfter * 1000)
            : min(2000, 250 * (2 ** ($attempt - 1)));

        if ($this->sleep !== null) {
            ($this->sleep)($milliseconds);
            return;
        }

        usleep($milliseconds * 1000);
    }
}

Notice what is absent from the logs: the token, response body, TLS details, and findings. Operational logs need status and attempt information, not credentials or a duplicate security report.

Compare scores and send the alert

The first successful run establishes a baseline without emailing anyone. Later successful runs update that baseline. If the score falls, the email is sent before state is saved. That produces at-least-once alert behavior: a mail failure leaves the old baseline intact so a later run can try again.

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

use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

final class SecurityMonitor
{
    public function __construct(
        private WebsiteAnalyzer $analyzer,
        private CacheItemPoolInterface $securityMonitorCache,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
        private string $websiteUrl,
        private string $ownerEmail,
        private string $fromEmail,
    ) {}

    public function run(): string
    {
        $snapshot = $this->analyzer->analyze($this->websiteUrl);
        $key = 'website_security_'.hash('sha256', $this->websiteUrl);
        $item = $this->securityMonitorCache->getItem($key);
        $stored = $item->isHit() ? $item->get() : null;
        $previous = is_array($stored) && is_numeric($stored['score'] ?? null)
            ? (float) $stored['score']
            : null;

        $outcome = $previous === null ? 'baseline' : 'steady';

        if ($previous !== null && $snapshot->score < $previous) {
            $body = sprintf(
                "Website: %s\nPrevious score: %s\nCurrent score: %s\n\n".
                "Findings by severity:\n%s\n\nTLS details:\n%s\n\n".
                "Recommendations:\n%s\n",
                $this->websiteUrl,
                $previous,
                $snapshot->score,
                json_encode($snapshot->findings, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                json_encode($snapshot->tls, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                json_encode($snapshot->recommendations, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
            );

            $this->mailer->send(
                (new Email())
                    ->from($this->fromEmail)
                    ->to($this->ownerEmail)
                    ->subject('Website security score dropped')
                    ->text($body),
            );

            $outcome = 'alerted';
        }

        $item->set([
            'score' => $snapshot->score,
            'checked_at' => gmdate(DATE_ATOM),
        ]);

        if (!$this->securityMonitorCache->save($item)) {
            throw new \RuntimeException('The security baseline could not be saved.');
        }

        $this->logger->info('Weekly website security check completed.', [
            'website' => $this->websiteUrl,
            'score' => $snapshot->score,
            'outcome' => $outcome,
        ]);

        return $outcome;
    }
}

Expose the workflow as a locked command

<?php
// src/Command/CheckWebsiteSecurityCommand.php
namespace App\Command;

use App\Security\SecurityMonitor;
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\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;

#[AsCommand(name: 'app:security:check')]
final class CheckWebsiteSecurityCommand extends Command
{
    public function __construct(
        private SecurityMonitor $monitor,
        private LockFactory $lockFactory,
        private LoggerInterface $logger,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $lock = $this->lockFactory->createLock(
            'weekly-website-security',
            300.0,
        );

        if (!$lock->acquire()) {
            $output->writeln('A security check is already running.');
            return Command::SUCCESS;
        }

        try {
            $outcome = $this->monitor->run();
            $output->writeln('Security check completed: '.$outcome);
            return Command::SUCCESS;
        } catch (\Throwable $e) {
            $this->logger->error('Website security check failed.', [
                'exception' => $e::class,
                'message' => $e->getMessage(),
            ]);
            $output->writeln('Security check failed; inspect application logs.');
            return Command::FAILURE;
        } finally {
            $lock->release();
        }
    }
}

Test both the boundary and the business rule

MockHttpClient makes the API test deterministic and verifies the exact method, endpoint, and request body.

<?php
namespace App\Tests\Security;

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

final class WebsiteSecurityAnalyzerTest extends TestCase
{
    public function testItMapsAValidResponse(): void
    {
        $client = new MockHttpClient(
            function (string $method, string $url, array $options): MockResponse {
                self::assertSame('POST', $method);
                self::assertSame(
                    'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
                    $url,
                );
                self::assertSame(
                    ['url' => 'https://www.example.com'],
                    json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR),
                );

                return new MockResponse(json_encode([
                    'score' => 84,
                    'findings' => ['high' => [], 'medium' => [['id' => 'header']]],
                    'tls' => ['available' => true],
                    'recommendations' => ['Review browser security headers.'],
                ], JSON_THROW_ON_ERROR));
            },
            'https://ai.mihajlo.mk/api/website-security-analyzer-api',
        );

        $snapshot = (new WebsiteSecurityAnalyzer(
            $client,
            new NullLogger(),
            static function (int $milliseconds): void {},
        ))->analyze('https://www.example.com');

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

A second test should exercise the outcome that matters: establishing a baseline and sending exactly one message after a drop.

<?php
public function testItEmailsOnlyAfterTheScoreDrops(): void
{
    $analyzer = $this->createMock(\App\Security\WebsiteAnalyzer::class);
    $analyzer->method('analyze')->willReturnOnConsecutiveCalls(
        new \App\Security\SecuritySnapshot(90, ['high' => []], [], []),
        new \App\Security\SecuritySnapshot(82, ['high' => [['id' => 'x']]], [], ['Review finding x.']),
    );

    $mailer = $this->createMock(\Symfony\Component\Mailer\MailerInterface::class);
    $mailer->expects(self::once())->method('send');

    $monitor = new \App\Security\SecurityMonitor(
        $analyzer,
        new \Symfony\Component\Cache\Adapter\ArrayAdapter(),
        $mailer,
        new \Psr\Log\NullLogger(),
        'https://www.example.com',
        '[email protected]',
        '[email protected]',
    );

    self::assertSame('baseline', $monitor->run());
    self::assertSame('alerted', $monitor->run());
}

Also add cases for malformed JSON, missing response fields, HTTP 401 without retries, HTTP 429 with bounded retries, email transport failure without a baseline update, and corrupted cached state.

Deploy and schedule the weekly check

Run tests, warm the production cache, verify mail delivery, and execute the command manually once. That first execution should report baseline and send no score-drop email.

php bin/phpunit
APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check

On a single server, a Monday cron entry can run the check at a quiet time:

17 3 * * 1 cd /srv/example-app && APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check

Configure the scheduler to capture the command’s non-zero exit status. Alert on repeated failures, because authentication errors, exhausted plan quota, DNS trouble, mail transport failures, and unwritable cache storage all prevent useful monitoring.

For container or multi-replica deployments, do not keep the baseline solely inside an ephemeral container. Use durable shared storage for the cache and a shared lock backend. Run the schedule from one designated worker or rely on the distributed lock.

Common production failures

  • 401 or 403: confirm the environment variable, plan activation, and token rotation. Do not retry these responses blindly.
  • 400 or 422: verify that SECURITY_WEBSITE_URL is a valid public HTTPS URL and that the JSON field remains url.
  • 429: respect the bounded retry behavior, inspect plan usage, and rerun later. Never turn rate limiting into an infinite loop.
  • No email after the first run: this is intentional. The first score creates the comparison baseline.
  • Repeated alerts: check whether email succeeds but persistent state cannot be saved, or whether replicas use separate cache pools.
  • Command succeeds but cron does not: cron often has a smaller environment. Use absolute paths and ensure required variables are available to the scheduled process.

Final verification checklist

  • The token exists only in environment-backed configuration and is absent from source control and logs.
  • The client calls the exact POST endpoint with a JSON url.
  • Connection and total response times are bounded.
  • Only transient failures and rate limits receive limited retries.
  • The first successful result creates a durable baseline without sending email.
  • A lower later score sends one useful report containing findings, TLS details, and recommendations.
  • Email or state-storage failures produce a non-zero command result.
  • The scheduler runs weekly and overlapping executions are locked.

The valuable part of this monitor is not the cron expression or even the email. It is the careful boundary around change: validate uncertain external data, remember the last trustworthy result, notify only when the signal worsens, and fail visibly when the monitoring system itself stops working. That turns a simple weekly API call into a dependable early-warning mechanism a small business 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.