Native PHP 8.3: Automate Production Security Scans Post-Deployment
A deployment can succeed while quietly weakening the site it delivers. A proxy change drops a security header, a certificate chain is misconfigured, or a new response policy gives browsers less protection than the previous release. These problems sit outside unit tests because they emerge only after the public HTTPS path is live.
This tutorial builds a Native PHP 8.3 command that calls the Website Security Analyzer after every production deployment. It performs a bounded, non-invasive review of public HTTPS and browser security posture, maps the result into a stable domain object, writes a deployment artifact, and returns an honest process status for automation. It is a security regression signal, not a penetration test.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Website Security Analyzer service page. Choose an available Free, Plus, or Pro plan and complete activation.
- Open the official service documentation. Find the Service token panel and copy the service-scoped token.
- Store that value in protected project environment configuration. Regenerating the service token revokes the previously active token, so token rotation must update production configuration before the next scan.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token because credentials remain out of URLs, proxy histories, and routine access logs.
Confirm the API contract
The exact request is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains url. Before building the command, make one minimal request from a controlled terminal:
curl --fail-with-body \
--connect-timeout 5 \
--max-time 30 \
--request POST \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example.com"}' \
https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Do not paste the real token into shell history on shared systems. For production, create a deployment-managed file outside the release directory, such as /var/www/example/shared/.env.production:
WEBSITE_SECURITY_TOKEN="YOUR_SERVICE_TOKEN"
PUBLIC_SITE_URL="https://www.example.com"
SECURITY_REPORT_PATH="/var/www/example/shared/security/latest.json"
Restrict this file to the deployment account and never commit it. Native PHP does not load environment files automatically, so our entry point will parse this explicitly with INI_SCANNER_RAW.
Architecture that fits a small production application
The design has four narrow pieces: a cURL transport, an API client with retry policy, a domain mapper, and a console command. The post-deployment hook invokes the command only after the new release is publicly reachable.
Running after deployment means a failed scan cannot honestly claim the release was prevented. Instead, the command exits nonzero, preserves the deployed site, and lets the pipeline alert a human or begin an explicitly designed rollback workflow. This avoids coupling public availability to a transient analyzer failure.
Use this compact structure:
security-scan/
├── composer.json
├── src/
│ ├── Http.php
│ └── SecurityAnalyzer.php
├── bin/
│ └── scan-production.php
└── tests/
└── SecurityAnalyzerClientTest.php
PHPUnit is the only development dependency. The application itself uses PHP, JSON, and cURL:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
Build a bounded cURL transport
The transport owns networking mechanics but knows nothing about score interpretation. It captures response headers so the client can respect Retry-After, and it never logs the token or response body.
<?php
// src/Http.php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function send(string $token, array $payload): HttpResponse;
}
final class TransportException extends \RuntimeException {}
final class CurlTransport implements Transport
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function send(string $token, array $payload): HttpResponse
{
$headers = [];
$handle = curl_init(self::ENDPOINT);
if ($handle === false) {
throw new TransportException('Could not initialize cURL');
}
$body = json_encode($payload, JSON_THROW_ON_ERROR);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 5_000,
CURLOPT_TIMEOUT_MS => 30_000,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => $body,
CURLOPT_HEADERFUNCTION => static function (
\CurlHandle $handle,
string $line
) use (&$headers): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $length;
},
]);
$responseBody = curl_exec($handle);
if ($responseBody === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException('Analyzer transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $headers, $responseBody);
}
}
Map the response at the application boundary
The service result includes a score, severity-grouped findings, TLS details, and recommendations. External responses should never flow unvalidated through the application. The mapper accepts only the expected shapes and fails if required contract data is malformed.
It deliberately preserves finding-group names and TLS properties rather than inventing a closed set that the supplied contract does not guarantee.
<?php
// src/SecurityAnalyzer.php
declare(strict_types=1);
namespace App;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $data): self
{
if (!isset($data['score']) || !is_numeric($data['score'])) {
throw new AnalyzerException('contract', 'Missing or invalid score');
}
foreach (['findings', 'tls', 'recommendations'] as $field) {
if (!isset($data[$field]) || !is_array($data[$field])) {
throw new AnalyzerException(
'contract',
'Missing or invalid ' . $field
);
}
}
$findings = [];
foreach ($data['findings'] as $severity => $items) {
if (is_string($severity) && is_array($items)) {
$findings[$severity] = $items;
}
}
$recommendations = array_values(array_filter(
$data['recommendations'],
static fn (mixed $item): bool => is_string($item)
));
return new self(
(float) $data['score'],
$findings,
$data['tls'],
$recommendations,
);
}
public function toArray(): array
{
return [
'score' => $this->score,
'findings' => $this->findings,
'tls' => $this->tls,
'recommendations' => $this->recommendations,
];
}
}
final class AnalyzerException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $httpStatus = null,
) {
parent::__construct($message);
}
}
final class SecurityAnalyzerClient
{
public function __construct(
private readonly Transport $transport,
private readonly \Closure $sleep =
new \Closure(),
) {}
public function analyze(string $url, string $token): SecurityReport
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new AnalyzerException('validation', 'A public HTTPS URL is required');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->send($token, ['url' => $url]);
} catch (TransportException $exception) {
if ($attempt === 3) {
throw new AnalyzerException('transport', $exception->getMessage());
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new AnalyzerException('contract', 'Invalid JSON response');
}
if (!is_array($decoded)) {
throw new AnalyzerException('contract', 'Invalid response object');
}
return SecurityReport::fromArray($decoded);
}
$retryable = $response->status === 408
|| $response->status === 429
|| $response->status >= 500;
if ($retryable && $attempt < 3) {
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? (int) $response->headers['retry-after'] * 1_000
: 250 * (2 ** ($attempt - 1));
($this->sleep)(min($retryAfter, 8_000));
continue;
}
$kind = match ($response->status) {
400, 422 => 'validation',
401, 403 => 'authentication',
429 => 'quota',
default => $response->status >= 500 ? 'upstream' : 'http',
};
throw new AnalyzerException(
$kind,
'Analyzer request failed with HTTP ' . $response->status,
$response->status
);
}
throw new AnalyzerException('internal', 'Retry loop ended unexpectedly');
}
}
Replace the constructor’s placeholder default closure with an explicit sleeper at composition time, as the command does below. Injecting it keeps retry tests instantaneous and deterministic. Validation and authentication failures are not retried; repeating an invalid URL or revoked token only wastes quota and delays diagnosis.
Create the production command
The command loads protected configuration, calls the client, writes the report atomically, and emits a single JSON log event. The temporary file prevents readers from observing a partially written artifact.
<?php
// bin/scan-production.php
declare(strict_types=1);
use App\AnalyzerException;
use App\CurlTransport;
use App\SecurityAnalyzerClient;
require dirname(__DIR__) . '/vendor/autoload.php';
$envFile = $argv[1] ?? '/var/www/example/shared/.env.production';
$values = parse_ini_file($envFile, false, INI_SCANNER_RAW);
if (!is_array($values)) {
fwrite(STDERR, "Could not load production environment\n");
exit(1);
}
$token = $values['WEBSITE_SECURITY_TOKEN'] ?? '';
$url = $values['PUBLIC_SITE_URL'] ?? '';
$reportPath = $values['SECURITY_REPORT_PATH'] ?? '';
if ($token === '' || $url === '' || $reportPath === '') {
fwrite(STDERR, "Required security scan configuration is missing\n");
exit(1);
}
$client = new SecurityAnalyzerClient(
new CurlTransport(),
static fn (int $milliseconds) => usleep($milliseconds * 1_000)
);
try {
$report = $client->analyze($url, $token);
$document = [
'scanned_at' => gmdate(DATE_ATOM),
'url' => $url,
'report' => $report->toArray(),
];
$json = json_encode(
$document,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);
$temporaryPath = $reportPath . '.tmp';
if (file_put_contents($temporaryPath, $json . PHP_EOL, LOCK_EX) === false
|| !rename($temporaryPath, $reportPath)) {
throw new RuntimeException('Could not persist the scan report');
}
fwrite(STDOUT, json_encode([
'event' => 'website_security_scan_completed',
'url' => $url,
'score' => $report->score,
'finding_groups' => array_map('count', $report->findings),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(0);
} catch (AnalyzerException $exception) {
fwrite(STDERR, json_encode([
'event' => 'website_security_scan_failed',
'url' => $url,
'kind' => $exception->kind,
'http_status' => $exception->httpStatus,
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(2);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'website_security_scan_failed',
'url' => $url,
'kind' => 'local',
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(3);
}
Logs intentionally omit the token, response body, and detailed findings. Send the structured events to the same log destination as deployment events, then alert on a nonzero exit or a missing completion event. Keep detailed reports in access-controlled storage because findings may describe weaknesses useful to an attacker.
Test retries and response mapping without the network
A deterministic fake transport proves payload construction, mapping, and failure behavior without consuming quota or depending on service availability.
<?php
// tests/SecurityAnalyzerClientTest.php
declare(strict_types=1);
namespace Tests;
use App\HttpResponse;
use App\SecurityAnalyzerClient;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public array $requests = [];
public function __construct(private array $responses) {}
public function send(string $token, array $payload): HttpResponse
{
$this->requests[] = compact('token', 'payload');
return array_shift($this->responses);
}
}
final class SecurityAnalyzerClientTest extends TestCase
{
public function testMapsSuccessfulReport(): void
{
$transport = new FakeTransport([
new HttpResponse(200, [], json_encode([
'score' => 91,
'findings' => ['high' => [], 'low' => [['name' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review browser policy.'],
], JSON_THROW_ON_ERROR)),
]);
$client = new SecurityAnalyzerClient($transport, static fn (int $ms) => null);
$report = $client->analyze('https://www.example.com', 'test-token');
self::assertSame(91.0, $report->score);
self::assertCount(1, $report->findings['low']);
self::assertSame(
['url' => 'https://www.example.com'],
$transport->requests[0]['payload']
);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], ''),
new HttpResponse(200, [], json_encode([
'score' => 80,
'findings' => [],
'tls' => [],
'recommendations' => [],
], JSON_THROW_ON_ERROR)),
]);
$delays = [];
$client = new SecurityAnalyzerClient(
$transport,
static function (int $ms) use (&$delays): void {
$delays[] = $ms;
}
);
$client->analyze('https://www.example.com', 'test-token');
self::assertCount(2, $transport->requests);
self::assertSame([1_000], $delays);
}
}
Run composer install and then composer test. The fixture values are synthetic application-boundary data, not claimed service measurements, and the fake token can never authenticate.
Wire it into deployment safely
After switching the release symlink and completing a public readiness check, invoke:
php /var/www/example/current/bin/scan-production.php \
/var/www/example/shared/.env.production
Allow the process roughly the combined timeout and bounded retry window. Do not launch it in an unobserved background shell. Capture its exit code and logs in the deployment system, while deciding explicitly whether exit code 2 should mark the deployment as unstable or trigger a notification.
Common failures are usually straightforward:
- HTTP 401 or 403: verify activation and replace a revoked or incorrectly scoped token. Do not retry automatically.
- HTTP 400 or 422: confirm that
PUBLIC_SITE_URLis the public HTTPS origin rather than an internal hostname. - HTTP 429: the client honors a bounded numeric
Retry-After, but persistent quota exhaustion needs plan or deployment-frequency review. - Transport or 5xx failure: retain the previous report, emit the failure event, and retry only within the bounded policy.
- Contract failure: preserve the raw response only in a secure diagnostic process; do not silently turn malformed fields into a clean report.
Final verification checklist
- The service plan is active and the service-scoped token resides only in protected environment configuration.
- The command sends exactly one JSON
urlfield to the documented POST endpoint. - Connection and total response times are bounded, with no retries for authentication or validation errors.
- Tests pass using a deterministic fake transport and make no external requests.
- A completed deployment runs the command against the real public HTTPS URL.
- Success produces an atomic report and structured completion event; failure produces a categorized nonzero result.
- Operators understand that the output is a bounded posture analysis, not evidence that the website passed a penetration test.
The lasting value is not a single reassuring score. It is the habit of checking the public system after reality has changed. When every production deployment closes with a bounded, observable security review, browser and TLS regressions become ordinary engineering signals: visible, actionable, and far less likely to remain unnoticed.