Tutorials

Laravel: Automate Post-Deployment Website Security Audits with an AI Analyzer

Laravel: Automate Post-Deployment Website Security Audits with an AI Analyzer

A deployment can succeed while quietly weakening a website’s security posture. A proxy change may remove a header, a certificate chain may be misconfigured, or a new route may expose browser-facing behavior that nobody intended. Functional smoke tests rarely catch those regressions.

This tutorial adds a bounded, non-invasive security audit to a Laravel production deployment. After each release becomes live, an Artisan command sends the public HTTPS URL to the Website Security Analyzer, maps the response into a strict domain object, stores a report, and produces actionable logs. It is a posture check, not a penetration test, vulnerability scan, or substitute for specialist security review.

Get access to the Website Security Analyzer

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.

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 query parameters can leak into access logs and monitoring systems.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update the production secret, refresh Laravel’s cached configuration, verify the integration, and only then consider the rotation complete.

Confirm access with the exact endpoint

The API call is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains the public URL:

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

Run that test only from a trusted shell whose history and diagnostic output are appropriately protected. Never paste a production token into source code, screenshots, tickets, logs, or test fixtures.

Store the credential and deployment target in the production environment configuration:

WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_SECURITY_AUDIT_URL=https://www.example.com

Architecture and production trade-offs

The deployment runner invokes one synchronous Artisan command after the new release is live and its normal health check has passed. The command calls a dedicated HTTP boundary, maps the response, and writes storage/app/private/security/latest.json.

A queue would add latency, worker coordination, and another failure surface without improving this short, bounded operation. A direct command also gives the deployment system an immediate exit code. Invoke it once per release from the deployment coordinator, not independently on every application node.

The implementation deliberately separates transport data from application code. The API boundary requires a numeric score plus arrays for severity-grouped findings, TLS details, and recommendations. It preserves their internal content without guessing undocumented nested fields.

Prerequisites are PHP 8.3 or newer, a maintained Laravel application with the built-in HTTP client, a publicly reachable HTTPS site, and permission to update deployment secrets and hooks. No third-party package is required.

app/
  Console/Commands/AuditPublicWebsite.php
  Security/WebsiteSecurityAnalyzer.php
  Security/WebsiteSecurityReport.php
config/
  services.php
tests/Feature/
  AuditPublicWebsiteTest.php

Configure the service boundary

Add the service configuration to config/services.php. Keeping the endpoint explicit prevents accidental calls to a similarly named route:

<?php

return [
    // Existing services...

    'website_security_analyzer' => [
        'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
        'token' => env('WEBSITE_SECURITY_ANALYZER_TOKEN'),
        'target_url' => env('WEBSITE_SECURITY_AUDIT_URL'),
    ],
];

Configuration is cached in most production deployments. After changing either environment value, run php artisan config:cache before the audit.

Map the response defensively

Create app/Security/WebsiteSecurityReport.php. The mapper rejects a response that violates the documented top-level contract instead of letting loosely shaped data spread through the application:

<?php

namespace App\Security;

use JsonSerializable;
use UnexpectedValueException;

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

    public static function fromApi(array $data): self
    {
        if (!isset($data['score']) || !is_numeric($data['score'])) {
            throw new UnexpectedValueException('Analyzer response has no numeric score.');
        }

        foreach (['findings', 'tls', 'recommendations'] as $field) {
            if (!array_key_exists($field, $data) || !is_array($data[$field])) {
                throw new UnexpectedValueException(
                    "Analyzer response field {$field} must be an array."
                );
            }
        }

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

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

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

The mapper does not assign meaning to particular severity labels or TLS subfields. That is intentional: consumers retain the service data, while changes to undocumented nested shapes do not silently become application promises.

Build a resilient Laravel HTTP client

Create app/Security/WebsiteSecurityAnalyzer.php. Timeouts bound deployment latency. Retries cover transient connections, rate limits, and selected server errors, while authentication and validation failures return immediately.

<?php

namespace App\Security;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

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

final class WebsiteSecurityAnalyzer
{
    public function analyze(string $url): WebsiteSecurityReport
    {
        $token = (string) config('services.website_security_analyzer.token');
        $endpoint = (string) config('services.website_security_analyzer.endpoint');

        if ($token === '') {
            throw new AnalyzerFailure('configuration', 'Analyzer token is missing.');
        }

        if (!$this->isPublicHttpsCandidate($url)) {
            throw new AnalyzerFailure(
                'configuration',
                'Audit target must be a public HTTPS URL.'
            );
        }

        try {
            $response = Http::withToken($token)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(5)
                ->timeout(20)
                ->retry(
                    [250, 750],
                    0,
                    function (Throwable $exception, PendingRequest $request): bool {
                        if ($exception instanceof ConnectionException) {
                            return true;
                        }

                        return $exception instanceof RequestException
                            && in_array(
                                $exception->response->status(),
                                [429, 500, 502, 503, 504],
                                true
                            );
                    },
                    throw: false,
                )
                ->post($endpoint, ['url' => $url]);
        } catch (ConnectionException $exception) {
            throw new AnalyzerFailure(
                'network',
                'Analyzer could not be reached after bounded retries.',
                previous: $exception,
            );
        }

        $status = $response->status();

        if (in_array($status, [401, 403], true)) {
            throw new AnalyzerFailure(
                'authentication',
                'Analyzer rejected the service token.',
                $status,
            );
        }

        if (in_array($status, [400, 422], true)) {
            throw new AnalyzerFailure(
                'validation',
                'Analyzer rejected the configured URL.',
                $status,
            );
        }

        if ($status === 429) {
            throw new AnalyzerFailure(
                'rate_limit',
                'Analyzer quota or rate limit was reached.',
                $status,
            );
        }

        if (!$response->successful()) {
            throw new AnalyzerFailure(
                'remote',
                'Analyzer returned an unsuccessful response.',
                $status,
            );
        }

        $data = $response->json();

        if (!is_array($data)) {
            throw new AnalyzerFailure('contract', 'Analyzer returned invalid JSON.');
        }

        try {
            return WebsiteSecurityReport::fromApi($data);
        } catch (Throwable $exception) {
            throw new AnalyzerFailure(
                'contract',
                'Analyzer response did not match the expected contract.',
                $status,
                $exception,
            );
        }
    }

    private function isPublicHttpsCandidate(string $url): bool
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        $parts = parse_url($url);

        return is_array($parts)
            && ($parts['scheme'] ?? null) === 'https'
            && isset($parts['host'])
            && !in_array(
                strtolower($parts['host']),
                ['localhost', '127.0.0.1', '::1'],
                true
            );
    }
}

The target comes from trusted deployment configuration, never an HTTP request. The local validation catches obvious mistakes; public reachability remains a deployment prerequisite. The token and response body are intentionally absent from exception messages.

Create the post-deployment command

Create app/Console/Commands/AuditPublicWebsite.php:

<?php

namespace App\Console\Commands;

use App\Security\AnalyzerFailure;
use App\Security\WebsiteSecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use JsonException;
use Throwable;

final class AuditPublicWebsite extends Command
{
    protected $signature = 'security:audit-website';
    protected $description = 'Audit the production website security posture';

    public function handle(WebsiteSecurityAnalyzer $analyzer): int
    {
        $url = (string) config('services.website_security_analyzer.target_url');

        try {
            $report = $analyzer->analyze($url);

            $document = [
                'generated_at' => now()->toIso8601String(),
                'target_url' => $url,
                'report' => $report,
            ];

            $json = json_encode(
                $document,
                JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR
            );

            if (!Storage::disk('local')->put(
                'security/latest.json',
                $json
            )) {
                throw new RuntimeException('Could not store the audit report.');
            }

            $counts = array_map('count', $report->findings);

            Log::info('Post-deployment security audit completed', [
                'target_host' => parse_url($url, PHP_URL_HOST),
                'score' => $report->score,
                'finding_counts' => $counts,
            ]);

            $this->info("Security audit completed; score {$report->score}.");

            return self::SUCCESS;
        } catch (AnalyzerFailure $exception) {
            Log::warning('Post-deployment security audit failed', [
                'failure_kind' => $exception->kind,
                'http_status' => $exception->status,
            ]);

            $this->error("Security audit failed: {$exception->kind}.");

            return self::FAILURE;
        } catch (JsonException|Throwable $exception) {
            Log::error('Post-deployment security audit crashed', [
                'exception' => $exception::class,
            ]);

            $this->error('Security audit failed locally.');

            return self::FAILURE;
        }
    }
}

If your Laravel version does not automatically discover commands in app/Console/Commands, register this class using that version’s standard console-command registration mechanism.

Test the complete path without calling production

Laravel’s Http::fake() makes the test deterministic and verifies the outbound contract. Create tests/Feature/AuditPublicWebsiteTest.php:

<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class AuditPublicWebsiteTest extends TestCase
{
    public function test_it_runs_and_stores_the_mapped_report(): void
    {
        Storage::fake('local');

        config([
            'services.website_security_analyzer.token' => 'test-token',
            'services.website_security_analyzer.target_url'
                => 'https://www.example.com',
        ]);

        Http::fake([
            'https://ai.mihajlo.mk/*' => Http::response([
                'score' => 86,
                'findings' => [
                    'high' => [],
                    'medium' => [['example' => 'preserved']],
                ],
                'tls' => ['example' => 'preserved'],
                'recommendations' => ['Review the reported finding.'],
            ], 200),
        ]);

        $this->artisan('security:audit-website')->assertSuccessful();

        Storage::disk('local')->assertExists('security/latest.json');

        Http::assertSent(function ($request): bool {
            return $request->method() === 'POST'
                && $request->url()
                    === 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
                && $request['url'] === 'https://www.example.com'
                && $request->hasHeader(
                    'Authorization',
                    'Bearer test-token'
                );
        });
    }

    public function test_it_does_not_retry_authentication_failures(): void
    {
        config([
            'services.website_security_analyzer.token' => 'invalid-token',
            'services.website_security_analyzer.target_url'
                => 'https://www.example.com',
        ]);

        Http::fake([
            'https://ai.mihajlo.mk/*' => Http::response([], 401),
        ]);

        $this->artisan('security:audit-website')->assertFailed();

        Http::assertSentCount(1);
    }
}

The synthetic nested values test preservation, not undocumented semantics. Add separate tests for malformed JSON shapes, a final 429, connection exhaustion, and storage failure if this command becomes a release gate.

Wire it into production deployment

Run migrations, cache configuration, switch traffic, and complete the application health check before invoking the audit. A fail-open deployment hook records the failure without rolling back an otherwise healthy release:

php artisan config:cache

if ! php artisan security:audit-website; then
  echo 'Post-deployment security audit failed' >&2
fi

Use a fail-closed hook only when the score and finding policy has been explicitly defined. The current command fails on transport or contract errors, but it does not invent a passing score or treat every reported finding as release-blocking.

Security, observability, and common failures

Keep the token in your deployment platform’s encrypted secret store and restrict environment access. Do not expose the stored report through a public disk or web route. Reports may reveal defensive gaps, so apply the same retention and access controls used for operational logs.

Logs should identify the release, target host, outcome, score, and counts by severity. They should never include authorization headers, the token, or an unfiltered response body. Alert separately on authentication, rate-limit, network, contract, and local-storage failures because each has a different owner and remedy.

  • 401 or 403: verify the service-scoped token and check whether regeneration revoked the deployed value.
  • 400 or 422: confirm that the configured target is a valid, publicly reachable HTTPS URL.
  • 429: review plan or quota availability. The client retries briefly, then exits instead of blocking deployment indefinitely.
  • Timeouts or 5xx responses: treat them as temporary service failures and preserve the previous successful report.
  • Contract failure: compare the response with the official documentation before changing the mapper.
  • Old credentials after rotation: update the secret and rebuild Laravel’s configuration cache.

Final verification checklist

  • The plan is active and the service token is stored only in environment-backed configuration.
  • The target uses public HTTPS and represents the production site visitors actually reach.
  • php artisan security:audit-website succeeds from the production runtime.
  • The request uses the exact POST endpoint and sends only the documented url field.
  • The report contains the score, severity-grouped findings, TLS details, and recommendations.
  • The private report artifact and structured logs are readable only by authorized operators.
  • Authentication and validation failures are not retried.
  • The deployment coordinator invokes the command exactly once after every live release.

A useful post-deployment audit does not need to be invasive or elaborate. Its value comes from running reliably, preserving the service’s findings without exaggerating them, and turning silent posture regressions into visible operational work. When every release ends with the same bounded check, security becomes part of deployment hygiene rather than an occasional act of memory.

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.