Tutorials

Native PHP 8.3: Weekly Website Security Score Alerts for Small Business Owners

Native PHP 8.3: Weekly Website Security Score Alerts for Small Business Owners

A website can remain online while quietly becoming less safe. A certificate renewal changes, a security header disappears during a redesign, or a hosting update weakens browser protections. Small business owners rarely need another dashboard; they need a clear warning when something has become worse.

This tutorial builds that warning as a Native PHP 8.3 application. Once a week, it submits one public HTTPS URL to the Website Security Analyzer, maps the returned score, severity-grouped findings, TLS details, and recommendations, compares the score with the previous successful result, and emails the owner only when the score drops.

The analysis is bounded and non-invasive. It evaluates public HTTPS and browser security posture; it is not a penetration test, vulnerability exploit, authenticated scan, or guarantee of security.

Get access and copy the service token

Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

  1. Open the Website Security Analyzer service page.
  2. Choose an available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Move the token immediately into environment-backed configuration.

Regenerating the service token revokes the previously active token, so coordinate rotation with deployment. This is not a token-free service: every request must authenticate using a Bearer token, an X-API-Token header, or the documented token query parameter. The implementation below uses a Bearer token because it keeps the credential out of URLs and routine access logs.

Confirm the API contract before building the scheduler

The exact request is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains url. Test the account with one minimal request, substituting the copied token only in your private terminal session:

curl --request POST \
  --url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://www.example.com"}'

Do not paste the response or token into tickets, screenshots, fixtures, or source control. Create a local .env file, exclude it from Git, and use placeholders in .env.example:

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_URL=https://www.example.com
[email protected]
[email protected]
STATE_FILE=/srv/site-watch/var/security-score.json

Keep the architecture small and explicit

The application needs only four production responsibilities: a cURL transport, an API boundary that validates external data, a weekly use case, and adapters for state and email. A database and queue would add operational burden without improving a single-site weekly check.

The state file is appropriate because only one scheduled process writes one score. A process lock prevents overlapping runs, while an atomic rename prevents readers from observing a partially written file. If several application nodes will execute the check, replace both mechanisms with shared storage and a distributed lock.

Use Composer for autoloading, environment loading, and PHPUnit:

{
  "name": "example/site-watch",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "vlucas/phpdotenv": "^5.6"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  },
  "autoload-dev": {
    "classmap": ["tests/"]
  },
  "scripts": {
    "test": "phpunit"
  }
}
composer install
mkdir -p src bin tests var
chmod 700 var
printf '%s\n' '.env' '/var/' >> .gitignore
composer dump-autoload

The resulting project contains src/SecurityAnalyzer.php, src/WeeklyCheck.php, bin/check-security.php, tests/SecurityAnalyzerTest.php, composer.json, and the private .env.

Build a defensive API boundary

External JSON is untrusted input even when it comes from a service you operate intentionally. The mapper below searches a limited nesting depth for the documented analysis fields, then validates their broad shapes without inventing TLS subfields or finding attributes. That preserves the service response while preventing malformed data from leaking deeper into the application.

<?php
declare(strict_types=1);

namespace App;

use Closure;
use JsonException;
use RuntimeException;
use UnexpectedValueException;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers = [],
    ) {}
}

interface HttpTransport
{
    public function postJson(string $url, string $token, array $body): HttpResponse;
}

final class TransportException extends RuntimeException {}

final class CurlTransport implements HttpTransport
{
    public function postJson(string $url, string $token, array $body): HttpResponse
    {
        $headers = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_HTTPHEADER => [
                'Accept: application/json',
                'Authorization: Bearer ' . $token,
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $bodyText = curl_exec($handle);
        if ($bodyText === false) {
            throw new TransportException(curl_error($handle));
        }

        return new HttpResponse(
            (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            $bodyText,
            $headers,
        );
    }
}

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

interface Analyzer
{
    public function analyze(string $url): Analysis;
}

final class AnalyzerException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly string $kind,
        public readonly ?int $status = null,
    ) {
        parent::__construct($message);
    }
}

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

    private readonly Closure $sleep;

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        ?callable $sleep = null,
    ) {
        if ($token === '') {
            throw new RuntimeException('SECURITY_ANALYZER_TOKEN is empty');
        }

        $this->sleep = Closure::fromCallable(
            $sleep ?? static fn (int $milliseconds) => usleep($milliseconds * 1000)
        );
    }

    public function analyze(string $url): Analysis
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https') {
            throw new RuntimeException('WEBSITE_URL must be a valid HTTPS URL');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    $this->token,
                    ['url' => $url],
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'Network failure after retries',
                        'network',
                        previous: $exception,
                    );
                }

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

            if ($response->status >= 200 && $response->status < 300) {
                return $this->map($response->body);
            }

            $retryable = $response->status === 408
                || $response->status === 429
                || $response->status >= 500;

            if ($retryable && $attempt < 3) {
                ($this->sleep)($this->backoffMilliseconds(
                    $attempt,
                    $response->headers['retry-after'] ?? null,
                ));
                continue;
            }

            $kind = match (true) {
                in_array($response->status, [401, 403], true) => 'authentication',
                in_array($response->status, [400, 422], true) => 'validation',
                $response->status === 429 => 'quota',
                $response->status >= 500 => 'upstream',
                default => 'http',
            };

            throw new AnalyzerException(
                'Analyzer request failed with HTTP ' . $response->status,
                $kind,
                $response->status,
            );
        }

        throw new AnalyzerException('Analyzer retry loop exhausted', 'internal');
    }

    private function backoffMilliseconds(int $attempt, ?string $retryAfter): int
    {
        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            return min((int) $retryAfter, 30) * 1000;
        }

        return (250 * (2 ** ($attempt - 1))) + random_int(0, 100);
    }

    private function map(string $body): Analysis
    {
        try {
            $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
        } catch (JsonException $exception) {
            throw new AnalyzerException('Analyzer returned invalid JSON', 'response', previous: $exception);
        }

        if (!is_array($decoded)) {
            throw new AnalyzerException('Analyzer response is not an object', 'response');
        }

        $data = $this->locateAnalysis($decoded);
        if (!is_numeric($data['score'])
            || !is_array($data['findings'])
            || !is_array($data['tls'])
            || !is_array($data['recommendations'])) {
            throw new AnalyzerException('Analyzer response has invalid field types', 'response');
        }

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

        return new Analysis(
            (float) $data['score'],
            $groups,
            $data['tls'],
            array_values($data['recommendations']),
        );
    }

    private function locateAnalysis(array $node, int $depth = 0): array
    {
        $required = ['score', 'findings', 'tls', 'recommendations'];
        if (count(array_intersect($required, array_keys($node))) === count($required)) {
            return $node;
        }

        if ($depth < 4) {
            foreach ($node as $value) {
                if (is_array($value)) {
                    try {
                        return $this->locateAnalysis($value, $depth + 1);
                    } catch (UnexpectedValueException) {
                    }
                }
            }
        }

        throw new UnexpectedValueException('Analysis fields were not found');
    }
}

The retry policy is deliberately narrow. Connection failures, timeouts, HTTP 408, HTTP 429, and server failures may be transient. Validation and authentication failures are not retried blindly. A numeric Retry-After value is honored but capped at 30 seconds so one scheduled process cannot stall indefinitely.

Compare scores and send one useful email

The use case saves the new score after successful notification. If email delivery fails, the old score remains in place, allowing the next run to try again. This ordering depends on the deployment-level single-process lock shown later.

<?php
declare(strict_types=1);

namespace App;

use RuntimeException;

interface ScoreStore
{
    public function load(string $url): ?float;
    public function save(string $url, float $score): void;
}

final readonly class FileScoreStore implements ScoreStore
{
    public function __construct(private string $path) {}

    public function load(string $url): ?float
    {
        if (!is_file($this->path)) {
            return null;
        }

        $data = json_decode((string) file_get_contents($this->path), true);
        return is_array($data)
            && ($data['url'] ?? null) === $url
            && is_numeric($data['score'] ?? null)
                ? (float) $data['score']
                : null;
    }

    public function save(string $url, float $score): void
    {
        $directory = dirname($this->path);
        $temporary = tempnam($directory, 'score-');
        if ($temporary === false) {
            throw new RuntimeException('Cannot create temporary state file');
        }

        try {
            $json = json_encode([
                'url' => $url,
                'score' => $score,
                'checked_at' => gmdate(DATE_ATOM),
            ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);

            if (file_put_contents($temporary, $json, LOCK_EX) === false) {
                throw new RuntimeException('Cannot write score state');
            }

            chmod($temporary, 0600);
            if (!rename($temporary, $this->path)) {
                throw new RuntimeException('Cannot replace score state');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }
    }
}

interface OwnerMailer
{
    public function scoreDropped(
        string $recipient,
        string $url,
        float $previous,
        Analysis $analysis,
    ): void;
}

final readonly class NativeOwnerMailer implements OwnerMailer
{
    public function __construct(private string $from) {}

    public function scoreDropped(
        string $recipient,
        string $url,
        float $previous,
        Analysis $analysis,
    ): void {
        foreach ([$recipient, $this->from] as $address) {
            if (filter_var($address, FILTER_VALIDATE_EMAIL) === false
                || preg_match('/[\r\n]/', $address)) {
                throw new RuntimeException('Invalid email configuration');
            }
        }

        $counts = [];
        foreach ($analysis->findingsBySeverity as $severity => $items) {
            $counts[] = $severity . ': ' . count($items);
        }

        $message = implode("\n", [
            'The website security score dropped.',
            'Website: ' . $url,
            'Previous score: ' . $previous,
            'Current score: ' . $analysis->score,
            'Finding counts: ' . ($counts ? implode(', ', $counts) : 'none'),
            'Review the analyzer recommendations before making changes.',
            '',
            'This is a bounded public HTTPS posture analysis, not a penetration test.',
        ]);

        if (!mail(
            $recipient,
            'Website security score dropped',
            $message,
            ['From' => $this->from, 'Content-Type' => 'text/plain; charset=UTF-8'],
        )) {
            throw new RuntimeException('Mail handoff failed');
        }
    }
}

final readonly class WeeklyCheck
{
    public function __construct(
        private Analyzer $analyzer,
        private ScoreStore $store,
        private OwnerMailer $mailer,
    ) {}

    public function run(string $url, string $owner): string
    {
        $analysis = $this->analyzer->analyze($url);
        $previous = $this->store->load($url);

        if ($previous !== null && $analysis->score < $previous) {
            $this->mailer->scoreDropped($owner, $url, $previous, $analysis);
            $result = 'drop_notified';
        } else {
            $result = $previous === null ? 'baseline_created' : 'no_drop';
        }

        $this->store->save($url, $analysis->score);
        return $result;
    }
}

PHP’s mail() reports only whether the local mail system accepted the message; it does not prove delivery. Production hosts therefore need a configured MTA or relay with bounce monitoring. The OwnerMailer boundary also makes it straightforward to substitute an approved transactional mail adapter later without changing score logic.

Wire the executable and structured logs

<?php
declare(strict_types=1);

use App\CurlTransport;
use App\FileScoreStore;
use App\NativeOwnerMailer;
use App\SecurityAnalyzerClient;
use App\WeeklyCheck;
use Dotenv\Dotenv;

require dirname(__DIR__) . '/vendor/autoload.php';

Dotenv::createImmutable(dirname(__DIR__))->safeLoad();

$required = static function (string $key): string {
    $value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
    if (!is_string($value) || $value === '') {
        throw new RuntimeException("Missing environment value: {$key}");
    }
    return $value;
};

try {
    $url = $required('WEBSITE_URL');
    $check = new WeeklyCheck(
        new SecurityAnalyzerClient(
            new CurlTransport(),
            $required('SECURITY_ANALYZER_TOKEN'),
        ),
        new FileScoreStore($required('STATE_FILE')),
        new NativeOwnerMailer($required('MAIL_FROM')),
    );

    $result = $check->run($url, $required('OWNER_EMAIL'));

    fwrite(STDOUT, json_encode([
        'event' => 'security_check_completed',
        'url_host' => parse_url($url, PHP_URL_HOST),
        'result' => $result,
        'time' => gmdate(DATE_ATOM),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(0);
} catch (Throwable $exception) {
    fwrite(STDERR, json_encode([
        'event' => 'security_check_failed',
        'exception' => $exception::class,
        'message' => $exception->getMessage(),
        'time' => gmdate(DATE_ATOM),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(1);
}

Logs contain the host, outcome, exception class, and time, but never the token, authorization header, response body, or complete findings. Alert operationally on nonzero exits or missing weekly completion events. A security-score email should not double as failure monitoring.

Test retries and the drop rule without network or email

A deterministic fake transport makes retry behavior testable without sleeping or consuming quota. Add this representative PHPUnit test:

<?php
declare(strict_types=1);

use App\AnalyzerException;
use App\HttpResponse;
use App\HttpTransport;
use App\SecurityAnalyzerClient;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function postJson(string $url, string $token, array $body): HttpResponse
    {
        $response = $this->responses[$this->calls++] ?? null;
        if (!$response instanceof HttpResponse) {
            throw new RuntimeException('Fake response queue exhausted');
        }
        return $response;
    }
}

final class SecurityAnalyzerTest extends TestCase
{
    public function testRetriesRateLimitThenMapsAnalysis(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '1']),
            new HttpResponse(200, json_encode([
                'score' => 82,
                'findings' => ['high' => [], 'medium' => [['id' => 'example']]],
                'tls' => ['present' => true],
                'recommendations' => ['Review the reported medium finding'],
            ], JSON_THROW_ON_ERROR)),
        ]);
        $delays = [];

        $client = new SecurityAnalyzerClient(
            $transport,
            'test-token',
            static function (int $milliseconds) use (&$delays): void {
                $delays[] = $milliseconds;
            },
        );

        $analysis = $client->analyze('https://www.example.com');

        self::assertSame(2, $transport->calls);
        self::assertSame([1000], $delays);
        self::assertSame(82.0, $analysis->score);
        self::assertCount(1, $analysis->findingsBySeverity['medium']);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([new HttpResponse(401, '{}')]);
        $client = new SecurityAnalyzerClient($transport, 'test-token', static fn () => null);

        try {
            $client->analyze('https://www.example.com');
            self::fail('Expected AnalyzerException');
        } catch (AnalyzerException $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(1, $transport->calls);
        }
    }
}
vendor/bin/phpunit --testdox
php bin/check-security.php

Add separate WeeklyCheck unit cases with in-memory implementations of Analyzer, ScoreStore, and OwnerMailer. Verify that the first result creates a baseline without email, an equal or higher score sends nothing, a lower score sends exactly one message, and a mail exception leaves the previous score unchanged.

Deploy one locked weekly process

Deploy with composer install --no-dev --classmap-authoritative, keep .env outside source control with mode 0600, and give the service user write access only to var. Confirm the host trusts current certificate authorities, permits outbound HTTPS, and has working mail delivery.

A systemd timer provides durable scheduling and journal logs. The timer below runs every Monday morning in the system manager’s configured timezone, adds a small random delay, catches missed runs after downtime, and uses flock to reject overlap:

# /etc/systemd/system/site-watch.service
[Unit]
Description=Weekly website security score check
After=network-online.target

[Service]
Type=oneshot
User=site-watch
WorkingDirectory=/srv/site-watch
EnvironmentFile=/srv/site-watch/.env
ExecStart=/usr/bin/flock -n /srv/site-watch/var/process.lock /usr/bin/php /srv/site-watch/bin/check-security.php

# /etc/systemd/system/site-watch.timer
[Unit]
Description=Run the website security score check weekly

[Timer]
OnCalendar=Mon *-*-* 08:00:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now site-watch.timer
sudo systemctl start site-watch.service
sudo systemctl status site-watch.service
sudo journalctl -u site-watch.service

Recognize common production failures

  • HTTP 401 or 403: verify plan activation and token deployment. If the token was regenerated, replace the revoked value everywhere.
  • HTTP 400 or 422: validate that WEBSITE_URL is a public HTTPS URL. Do not retry an unchanged request.
  • HTTP 429: respect the bounded retry delay, then treat continued rejection as a quota failure. Confirm the selected plan supports the intended schedule.
  • Timeouts or server failures: allow the three bounded attempts, retain the previous score, exit nonzero, and alert through operations monitoring.
  • No email: inspect the service journal and local mail logs. A successful mail() handoff is not proof that the owner’s provider accepted the message.
  • Baseline unexpectedly recreated: check state-file ownership, persistence, JSON integrity, and whether WEBSITE_URL changed.

Final verification checklist

  • The service plan is active and its scoped token exists only in protected environment configuration.
  • The exact POST endpoint succeeds with a JSON body containing url.
  • The configured target uses public HTTPS.
  • The initial run writes a protected baseline and sends no drop alert.
  • Tests prove transient retry, authentication non-retry, and defensive response mapping.
  • A controlled lower-score test sends one email before replacing stored state.
  • The timer is enabled, overlap is locked, failures exit nonzero, and logs expose no credentials.
  • The owner understands that the report covers public HTTPS posture and is not a penetration test.

The best small-business automation is often quiet: one bounded request, one durable comparison, and one message only when attention is warranted. With strict boundary validation, careful retries, protected state, and observable scheduling, a weekly score becomes more than a number. It becomes an early-warning signal the owner 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.