Vodiči

Laravel Deployments: Auto-Scan Your Live Site for Security Issues

Laravel implementacije: Automatski skenirajte svoju aktivnu stranicu radi sigurnosnih problema

A deployment can succeed while quietly weakening the site it just released. A proxy rule disappears, a security header is dropped, a certificate chain changes, or a new response exposes more than intended. Unit tests rarely catch these problems because they inspect the application, not the public HTTPS surface that browsers actually receive.

This tutorial adds a post-deployment security scan to a Laravel application. After production traffic has switched to the new release and a health check passes, an Artisan command analyzes the public URL, validates the response, stores a report, and records a concise operational event.

The analyzer performs bounded, non-invasive checks of public HTTPS and browser security posture. Its result is useful deployment evidence, but it is not a penetration test, vulnerability assessment, or substitute for authenticated security testing.

Get access and create a service token

Complete the service onboarding before writing integration code:

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

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because query parameters are more likely to appear in proxy, browser, and access logs.

Regenerating the service token revokes the previously active token. Treat rotation as a coordinated deployment: update the production secret, clear or rebuild cached Laravel configuration, verify a scan, and only then remove any temporary recovery procedure.

Confirm the API contract

The integration sends an HTTP POST request to this exact endpoint:

https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website

The JSON request contains one field, url. Make a minimal request from a secure terminal before building the Laravel feature:

export WEBSITE_SECURITY_TOKEN='YOUR_SERVICE_TOKEN'

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

Do not paste a production token into shell history, source control, screenshots, test fixtures, or deployment logs. The exported placeholder above is illustrative; use your shell or CI platform’s protected-secret mechanism in practice.

Now place the credential in Laravel’s environment-backed configuration. Production values should come from the hosting platform’s secret store rather than a committed file:

APP_URL=https://www.example.com
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
RELEASE_SHA=unknown

Keep the token out of .env.example; that file should contain only an empty placeholder. Laravel’s .env must already be excluded from version control.

Use a small, synchronous deployment boundary

A queue is unnecessary here. The deployment system already provides sequencing, exit codes, and logs, while a queued job could start before traffic switches or disappear behind a stopped worker. A synchronous Artisan command gives the pipeline a definite answer after the public release becomes reachable.

The implementation uses four focused pieces:

  • config/services.php holds the endpoint and environment-backed token.
  • app/Domain/Security/SecurityReport.php validates and maps the external response.
  • app/Services/WebsiteSecurityAnalyzer.php owns HTTP behavior and failure classification.
  • app/Console/Commands/ScanDeployedWebsite.php runs the scan and stores deployment evidence.

Add the service configuration:

'website_security' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
    'token' => env('WEBSITE_SECURITY_TOKEN'),
    'release' => env('RELEASE_SHA', 'unknown'),
],

Running php artisan config:cache during deployment freezes these environment values into Laravel’s configuration cache. Application code should therefore read config(), never call env() outside configuration files.

Map the response at the application boundary

The useful response concepts are the score, severity-grouped findings, TLS details, and recommendations. Their nested contents may evolve, so the mapper verifies the documented top-level shapes without inventing fields inside findings or TLS data.

<?php

namespace App\Domain\Security;

use UnexpectedValueException;

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

    public static function fromApi(array $payload): self
    {
        if (!isset($payload['score']) ||
            !is_int($payload['score']) && !is_float($payload['score'])) {
            throw new UnexpectedValueException('Analyzer score is missing or invalid.');
        }

        foreach (['findings', 'tls', 'recommendations'] as $field) {
            if (!isset($payload[$field]) || !is_array($payload[$field])) {
                throw new UnexpectedValueException(
                    "Analyzer field [{$field}] is missing or invalid."
                );
            }
        }

        foreach ($payload['findings'] as $severity => $findings) {
            if (!is_string($severity) || !is_array($findings)) {
                throw new UnexpectedValueException(
                    'Analyzer findings are not grouped by severity.'
                );
            }
        }

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

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

This defensive boundary matters. A successful HTTP status with malformed JSON is still an integration failure, not an empty report or a perfect score.

Build the resilient HTTP client

The client uses Laravel’s built-in HTTP facade with separate connection and total timeouts. It retries connection failures and server errors with bounded backoff. Authentication and validation failures are never retried. A rate-limited request is retried only when the server supplies a short numeric Retry-After value; longer waits or exhausted quotas belong in the next deployment or a deliberately scheduled retry.

<?php

namespace App\Services;

use App\Domain\Security\SecurityReport;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
use UnexpectedValueException;

final class AnalyzerException 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
{
    private readonly Closure $sleep;

    public function __construct(
        private readonly string $token,
        private readonly string $endpoint,
        ?Closure $sleep = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $milliseconds) =>
            usleep($milliseconds * 1000);
    }

    public function analyze(string $url): SecurityReport
    {
        $parts = parse_url($url);

        if (filter_var($url, FILTER_VALIDATE_URL) === false ||
            ($parts['scheme'] ?? null) !== 'https' ||
            empty($parts['host']) ||
            isset($parts['user']) ||
            isset($parts['pass'])) {
            throw new AnalyzerException(
                'validation',
                'The configured target must be a public HTTPS URL without credentials.'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($this->token)
                    ->acceptJson()
                    ->asJson()
                    ->connectTimeout(5)
                    ->timeout(30)
                    ->post($this->endpoint, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'transport',
                        'Unable to connect to the analyzer.',
                        previous: $exception,
                    );
                }

                ($this->sleep)($attempt === 1 ? 250 : 750);
                continue;
            }

            if ($response->successful()) {
                $payload = $response->json();

                if (!is_array($payload)) {
                    throw new AnalyzerException(
                        'contract',
                        'Analyzer returned invalid JSON.',
                        $response->status(),
                    );
                }

                try {
                    return SecurityReport::fromApi($payload);
                } catch (UnexpectedValueException $exception) {
                    throw new AnalyzerException(
                        'contract',
                        $exception->getMessage(),
                        $response->status(),
                        $exception,
                    );
                }
            }

            $status = $response->status();

            if ($status === 401 || $status === 403) {
                throw new AnalyzerException('authentication', 'Token rejected.', $status);
            }

            if ($status === 422) {
                throw new AnalyzerException('validation', 'Target URL rejected.', $status);
            }

            if ($status === 429) {
                $header = trim((string) $response->header('Retry-After'));
                $seconds = ctype_digit($header) ? (int) $header : null;

                if ($attempt < 3 && $seconds !== null && $seconds <= 5) {
                    ($this->sleep)($seconds * 1000);
                    continue;
                }

                throw new AnalyzerException(
                    'rate_limit',
                    'Analyzer quota or rate limit reached.',
                    $status,
                );
            }

            if ($status >= 500 && $attempt < 3) {
                ($this->sleep)($attempt === 1 ? 250 : 750);
                continue;
            }

            throw new AnalyzerException('http', 'Analyzer request failed.', $status);
        }

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

Bind the client in app/Providers/AppServiceProvider.php. Fail early when the token is absent instead of sending a doomed request:

use App\Services\WebsiteSecurityAnalyzer;

public function register(): void
{
    $this->app->singleton(
        WebsiteSecurityAnalyzer::class,
        function (): WebsiteSecurityAnalyzer {
            $token = config('services.website_security.token');

            if (!is_string($token) || $token === '') {
                throw new RuntimeException('WEBSITE_SECURITY_TOKEN is not configured.');
            }

            return new WebsiteSecurityAnalyzer(
                $token,
                (string) config('services.website_security.endpoint'),
            );
        }
    );
}

Create the post-deployment command

The command scans only APP_URL, limiting accidental or malicious use as a general URL scanner. It writes a timestamped report and a latest snapshot to Laravel’s local disk. Logs contain operational metadata, not the token or complete response.

<?php

namespace App\Console\Commands;

use App\Services\AnalyzerException;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;

final class ScanDeployedWebsite extends Command
{
    protected $signature = 'security:scan-deployed';
    protected $description = 'Analyze the public production website security posture';

    public function handle(WebsiteSecurityAnalyzer $analyzer): int
    {
        $url = (string) config('app.url');
        $release = (string) config('services.website_security.release');

        try {
            $report = $analyzer->analyze($url);
            $document = [
                'scanned_at' => now()->utc()->toIso8601String(),
                'url' => $url,
                'release' => $release,
                'report' => $report->toArray(),
            ];

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

            $name = now()->utc()->format('Ymd\THis\Z');
            Storage::disk('local')->put("security-scans/{$name}.json", $json);
            Storage::disk('local')->put('security-scans/latest.json', $json);

            Log::info('Post-deployment security scan completed.', [
                'url' => $url,
                'release' => $release,
                'score' => $report->score,
                'finding_counts' => array_map(
                    'count',
                    $report->findingsBySeverity
                ),
            ]);

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

            return self::SUCCESS;
        } catch (AnalyzerException $exception) {
            Log::error('Post-deployment security scan failed.', [
                'url' => $url,
                'release' => $release,
                'kind' => $exception->kind,
                'status' => $exception->status,
            ]);

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

            return self::FAILURE;
        } catch (Throwable $exception) {
            Log::error('Unable to persist post-deployment security scan.', [
                'url' => $url,
                'release' => $release,
                'exception' => $exception::class,
            ]);

            return self::FAILURE;
        }
    }
}

Laravel discovers commands placed in app/Console/Commands in current application skeletons. If an older or customized application does not, register the command using that application’s existing console bootstrap convention.

Test success, retries, and permanent failures

Use Http::fake() so tests never consume quota or depend on the network. The injected no-op sleeper keeps retry tests deterministic and fast.

<?php

namespace Tests\Unit;

use App\Services\AnalyzerException;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

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

    public function test_it_maps_a_valid_report_and_sends_bearer_auth(): void
    {
        Http::fake([
            self::ENDPOINT => Http::response([
                'score' => 88,
                'findings' => ['high' => [], 'low' => [['id' => 'header']]],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported header.'],
            ]),
        ]);

        $client = new WebsiteSecurityAnalyzer(
            'test-token',
            self::ENDPOINT,
            static fn (int $milliseconds) => null,
        );

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

        $this->assertSame(88, $report->score);
        $this->assertArrayHasKey('low', $report->findingsBySeverity);

        Http::assertSent(fn (Request $request) =>
            $request->url() === self::ENDPOINT &&
            $request->hasHeader('Authorization', 'Bearer test-token') &&
            $request['url'] === 'https://www.example.com'
        );
    }

    public function test_it_retries_a_server_error_then_succeeds(): void
    {
        Http::fake([
            self::ENDPOINT => Http::sequence()
                ->push([], 503)
                ->push([
                    'score' => 90,
                    'findings' => [],
                    'tls' => [],
                    'recommendations' => [],
                ], 200),
        ]);

        $client = new WebsiteSecurityAnalyzer(
            'test-token',
            self::ENDPOINT,
            static fn (int $milliseconds) => null,
        );

        $this->assertSame(
            90,
            $client->analyze('https://www.example.com')->score
        );

        Http::assertSentCount(2);
    }

    public function test_it_does_not_retry_rejected_authentication(): void
    {
        Http::fake([
            self::ENDPOINT => Http::response([], 401),
        ]);

        $client = new WebsiteSecurityAnalyzer(
            'bad-token',
            self::ENDPOINT,
            static fn (int $milliseconds) => null,
        );

        try {
            $client->analyze('https://www.example.com');
            $this->fail('Expected authentication failure.');
        } catch (AnalyzerException $exception) {
            $this->assertSame('authentication', $exception->kind);
        }

        Http::assertSentCount(1);
    }
}

Run the suite with php artisan test. A command-level test should additionally fake the local storage disk, invoke security:scan-deployed, and assert that security-scans/latest.json exists. Keep all test tokens synthetic.

Place the scan after traffic activation

The analyzer must see the deployed site, not the previous release. Put the command after the platform-specific traffic switch and a public health check. The following is the relevant tail of a production deployment script; APP_URL must be supplied to the shell and Laravel with the same value:

set -euo pipefail

php artisan config:cache
php artisan migrate --force

# Perform the platform-specific release activation before this point.

curl --fail --silent --show-error \
  --retry 4 \
  --retry-all-errors \
  "${APP_URL}/" >/dev/null

php artisan security:scan-deployed

A transport, authentication, malformed-response, or storage failure returns a nonzero exit code and should mark the post-deployment check as failed. Because traffic has already switched, wire that result to an alert or deployment incident rather than assuming an automatic rollback occurred.

A successful API call remains successful even when it contains findings. That distinction is intentional: the integration should preserve evidence and notify maintainers, while a separate, explicit policy decides which severities block promotion or trigger rollback. Security posture is too consequential for an undocumented threshold hidden inside an HTTP adapter.

Common production failures

  • 401 or 403: the token is missing, revoked, copied incorrectly, or belongs to a different service. Replace the secret, rebuild the configuration cache, and retry once.
  • 422: confirm that APP_URL is a valid public HTTPS URL. Do not retry unchanged input.
  • 429: respect Retry-After. A persistent response usually needs quota review, a later run, or an appropriate plan rather than aggressive retries.
  • 5xx or connection failure: the bounded retries absorb brief disruption. Continued failure should alert maintainers without exposing response bodies or credentials.
  • The old release is scanned: move the command after traffic activation and verify public DNS, CDN, and cache behavior.
  • No report survives deployment: release-local storage may be ephemeral. Configure Laravel’s local disk to use persistent shared storage, or adapt the command to an already approved durable disk.

Final verification checklist

  • The account and Free, Plus, or Pro service plan are active.
  • The service-scoped token is stored only in protected environment configuration.
  • APP_URL is the canonical public HTTPS address.
  • php artisan test passes without making real external requests.
  • php artisan config:cache completes with the production secret available.
  • The traffic switch and public health check run before php artisan security:scan-deployed.
  • A timestamped report and security-scans/latest.json are persisted.
  • Logs include the release, score, severity counts, and structured failure kind, but never the token.
  • Deployment alerts distinguish analyzer failure from reported security findings.

The valuable shift is not merely adding another API call. It is making the public website itself part of the release contract. Application tests tell you what the code intended; a bounded post-deployment scan records what the internet can actually observe. That final check turns an easy-to-forget security review into a repeatable property of every production deployment.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.