Tutorials

Native PHP 8.3: Automate Post-Deployment Website Security Audits

Native PHP 8.3: Automate Post-Deployment Website Security Audits

A production deployment is not finished merely because the new release answers health checks. Changes to redirects, proxy rules, certificates, response headers, or content security policy can quietly weaken the public site while the application itself remains healthy.

This tutorial builds a Native PHP 8.3 command that runs after every production deployment, submits the public HTTPS URL to the Website Security Analyzer, maps the result into a small domain object, and writes an atomic JSON audit artifact. The integration uses native cURL, bounded retries, deterministic tests, and explicit failure states.

The analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. Treat its output as deployment feedback, not as a penetration test, vulnerability assessment, or proof that a site is secure.

Get access and copy the service token

  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.
  6. Store it in your project’s deployment environment configuration. Regenerating the token revokes the previously active token, so deployments and secret stores must be updated together.

This service requires authentication; there is no no-token mode for this integration. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because query parameters can be captured by access logs, proxies, and monitoring systems.

Verify the endpoint before writing PHP

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

export WEBSITE_SECURITY_ANALYZER_TOKEN='YOUR_SERVICE_TOKEN'

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

Use a disposable shell or CI secret injection rather than placing a real token in terminal history. The response contract includes a score, findings grouped by severity, TLS details, and recommendations. We will not assume undocumented fields inside those collections.

Architecture and project shape

The design stays deliberately small: the deployment hook invokes a command, the command calls a dedicated API client, and the client delegates network I/O to a transport interface. That interface makes PHPUnit tests deterministic without contacting the service.

security-audit/
├── bin/audit-production.php
├── src/AnalyzerClient.php
├── src/AnalyzerResult.php
├── src/Http/CurlTransport.php
├── src/Http/HttpResponse.php
├── src/Http/HttpTransport.php
├── tests/AnalyzerClientTest.php
├── composer.json
└── phpunit.xml

The command runs synchronously after deployment. That makes completion visible to CI and avoids adding a queue solely for one short integration. Because the release may already be live, a failed audit marks the deployment workflow as failed but does not pretend to roll back infrastructure automatically.

Prerequisites and environment configuration

  • PHP 8.3 or newer with the cURL and JSON extensions.
  • Composer.
  • A public HTTPS production URL reachable by the analyzer.
  • A service-scoped token stored outside version control.

Install PHPUnit 11, which supports PHP 8.3, and configure PSR-4 autoloading:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload
mkdir -p var/security-audit

On the production host, create an environment file readable only by the deployment account. Do not commit it:

# /etc/my-site/security-audit.env
WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
PRODUCTION_PUBLIC_URL=https://www.example.com
SECURITY_AUDIT_OUTPUT=/srv/my-site/current/var/security-audit/latest.json

Apply restrictive file permissions through your provisioning system. Avoid printing this file or enabling shell tracing while it is loaded.

Build the native cURL boundary

The transport returns only status, response headers, and body. It enables certificate verification, limits connection and total duration, and deliberately avoids logging request headers.

<?php
// src/Http/HttpResponse.php
namespace App\Http;

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

// src/Http/HttpTransport.php
namespace App\Http;

interface HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse;
}
<?php
// src/Http/CurlTransport.php
namespace App\Http;

use JsonException;
use RuntimeException;

final class CurlTransport implements HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        try {
            $payload = json_encode($body, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new RuntimeException('Could not encode request JSON.', 0, $e);
        }

        $handle = curl_init($url);
        if ($handle === false) {
            throw new RuntimeException('Could not initialize cURL.');
        }

        $responseHeaders = [];
        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return $length;
            },
        ]);

        $bodyText = curl_exec($handle);
        if ($bodyText === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Analyzer transport failed: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $responseHeaders, $bodyText);
    }
}

Map the service response defensively

The domain object recognizes only the supplied contract. It requires the four top-level result areas but preserves their undocumented inner data instead of inventing a schema.

<?php
// src/AnalyzerResult.php
namespace App;

use UnexpectedValueException;

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

    public static function fromArray(array $data): self
    {
        if (!isset($data['score']) || !is_numeric($data['score'])) {
            throw new UnexpectedValueException('Response score is missing or invalid.');
        }
        if (!isset($data['findings']) || !is_array($data['findings'])) {
            throw new UnexpectedValueException('Response findings are missing or invalid.');
        }
        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new UnexpectedValueException('Findings are not grouped by severity.');
            }
        }
        if (!isset($data['tls']) || !is_array($data['tls'])) {
            throw new UnexpectedValueException('Response TLS details are missing or invalid.');
        }
        if (!isset($data['recommendations']) || !is_array($data['recommendations'])) {
            throw new UnexpectedValueException('Response recommendations are missing or invalid.');
        }

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

    public function toArray(): array
    {
        return [
            'score' => $this->score,
            'findings' => $this->findingsBySeverity,
            'tls' => $this->tls,
            'recommendations' => $this->recommendations,
        ];
    }
}

The client retries transport failures, HTTP 429, and server errors. It does not retry authentication or other client errors: repeating an invalid token or malformed request only consumes time and quota. Both exponential backoff and server-requested delays are capped.

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

use App\Http\HttpTransport;
use Closure;
use JsonException;
use RuntimeException;

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

    private Closure $sleep;

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        ?Closure $sleep = null,
    ) {
        if ($token === '') {
            throw new RuntimeException('Analyzer token is empty.');
        }
        $this->sleep = $sleep ?? static fn(int $microseconds) => usleep($microseconds);
    }

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

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: application/json',
                        'Content-Type: application/json',
                    ],
                    ['url' => $url],
                    3000,
                    20000,
                );
            } catch (RuntimeException $e) {
                if ($attempt === 3) {
                    throw $e;
                }
                ($this->sleep)(250000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 429 || $response->status >= 500) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Analyzer temporarily unavailable; HTTP ' . $response->status
                    );
                }
                $seconds = ctype_digit($response->headers['retry-after'] ?? '')
                    ? min(5, (int) $response->headers['retry-after'])
                    : 0;
                ($this->sleep)($seconds > 0
                    ? $seconds * 1000000
                    : 250000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new RuntimeException('Analyzer authentication was rejected.');
            }
            if ($response->status < 200 || $response->status >= 300) {
                throw new RuntimeException(
                    'Analyzer rejected the request; HTTP ' . $response->status
                );
            }

            try {
                $decoded = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
            } catch (JsonException $e) {
                throw new RuntimeException('Analyzer returned invalid JSON.', 0, $e);
            }
            if (!is_array($decoded)) {
                throw new RuntimeException('Analyzer returned an invalid document.');
            }

            return AnalyzerResult::fromArray($decoded);
        }

        throw new RuntimeException('Analyzer retry loop ended unexpectedly.');
    }
}

Create the deployment command

The command records either completed or unavailable. It writes via a temporary file and rename, so readers never observe half-written JSON. The error log contains the target and reason, but never the token or response body.

<?php
// bin/audit-production.php
declare(strict_types=1);

use App\AnalyzerClient;
use App\Http\CurlTransport;

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

$output = getenv('SECURITY_AUDIT_OUTPUT')
    ?: dirname(__DIR__) . '/var/security-audit/latest.json';
$target = getenv('PRODUCTION_PUBLIC_URL') ?: null;

try {
    $token = getenv('WEBSITE_SECURITY_ANALYZER_TOKEN');
    if ($target === null || $token === false || $token === '') {
        throw new RuntimeException('Required audit environment is missing.');
    }

    $result = (new AnalyzerClient(new CurlTransport(), $token))->analyze($target);
    $record = [
        'status' => 'completed',
        'checked_at' => gmdate(DATE_ATOM),
        'target_url' => $target,
        'analysis' => $result->toArray(),
        'scope' => 'bounded public HTTPS and browser security posture analysis',
    ];
    $exitCode = 0;
} catch (Throwable $e) {
    $record = [
        'status' => 'unavailable',
        'checked_at' => gmdate(DATE_ATOM),
        'target_url' => $target,
        'reason' => $e->getMessage(),
    ];
    fwrite(STDERR, json_encode([
        'event' => 'website_security_audit_failed',
        'target_url' => $target,
        'reason' => $e->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    $exitCode = 2;
}

$directory = dirname($output);
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
    throw new RuntimeException('Could not create audit output directory.');
}

$json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$temporary = $output . '.tmp.' . getmypid();

if (file_put_contents($temporary, $json . PHP_EOL, LOCK_EX) === false
    || !rename($temporary, $output)) {
    throw new RuntimeException('Could not persist audit result.');
}

fwrite(STDOUT, json_encode([
    'event' => 'website_security_audit_finished',
    'status' => $record['status'],
    'target_url' => $target,
], JSON_THROW_ON_ERROR) . PHP_EOL);

exit($exitCode);

Test mapping, retries, and authentication failures

A fake transport provides queued responses and counts calls. The injected no-op sleeper keeps retry tests fast and deterministic.

<?php
// tests/AnalyzerClientTest.php
use App\AnalyzerClient;
use App\Http\HttpResponse;
use App\Http\HttpTransport;
use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $responses) {}

    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class AnalyzerClientTest extends TestCase
{
    private function response(int $status, array $headers = []): HttpResponse
    {
        return new HttpResponse(
            $status,
            $headers,
            '{"score":91,"findings":{},"tls":{},"recommendations":[]}'
        );
    }

    public function testMapsDocument(): void
    {
        $fake = new FakeTransport([$this->response(200)]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        self::assertSame(91, $client->analyze('https://www.example.com')->score);
        self::assertSame(1, $fake->calls);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $fake = new FakeTransport([
            $this->response(429, ['retry-after' => '1']),
            $this->response(200),
        ]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        $client->analyze('https://www.example.com');
        self::assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([$this->response(401)]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        $this->expectExceptionMessage('authentication was rejected');
        try {
            $client->analyze('https://www.example.com');
        } finally {
            self::assertSame(1, $fake->calls);
        }
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="security-audit">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>
vendor/bin/phpunit

Attach it to production deployment

Run the command only after the public release and its HTTPS route are available. A generic deployment stage can load the protected environment file and invoke the audit:

set -eu
set -a
. /etc/my-site/security-audit.env
set +a

cd /srv/my-site/current
php bin/audit-production.php

Keep the API call out of browser requests. Deployment is the correct boundary: users do not wait for it, retries are controlled, and the resulting artifact can be collected by CI or monitoring. Alert on the structured website_security_audit_failed event and retain audit files according to your normal operational policy.

Do not automatically fail releases based on an assumed meaning for a score range. First confirm the documented score semantics and define an explicit product policy. Transport failure, a finding, and a policy violation are different states and should remain distinguishable.

Common production failures

  • HTTP 401 or 403: confirm plan activation and token injection. If the token was regenerated, replace the revoked value everywhere.
  • HTTP 429: the client honors a numeric Retry-After value up to five seconds. Persistent quota pressure should be resolved through scheduling or plan capacity, not aggressive retries.
  • Timeouts or server errors: three bounded attempts cover brief disruption. Continued failure produces an unavailable artifact and exit code 2.
  • Invalid response shape: treat contract drift as an integration failure. Preserve the raw response only in a tightly controlled diagnostic process; do not dump it indiscriminately into logs.
  • Private or pre-release URL: the service analyzes a public website. Run it after DNS, TLS, proxy, and authentication changes make the intended production URL publicly reachable.

Final verification checklist

  • The configured target is the canonical public HTTPS URL, not an internal hostname.
  • The real service token exists only in environment-backed secret configuration.
  • PHP has cURL and JSON enabled, Composer dependencies are installed, and PHPUnit passes.
  • The deployment account can write the audit directory without granting public web access to it.
  • A manual production run creates valid JSON containing status, timestamp, target, score, grouped findings, TLS details, and recommendations.
  • Authentication failures are not retried, while rate limits and transient failures use capped backoff.
  • The deployment workflow runs the command after every production release and surfaces exit code 2.
  • Operational documentation states that the result is bounded posture analysis, not a penetration test.

A useful deployment audit is not the loudest security mechanism in the system. It is the dependable one: narrowly scoped, safely authenticated, honest about failure, and present after every release. With this command in place, public-facing security posture becomes part of shipping software rather than an occasional task remembered after something has already gone wrong.

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.