Laravel Deployment Guardian: Automate Security Checks Post-Push
A deployment can succeed while quietly weakening a website. A proxy change drops a security header, a certificate chain is misconfigured, or a new response policy no longer protects browsers as intended. Unit tests rarely detect those problems because they inspect the application before the public delivery path has finished transforming it.
This tutorial adds a post-deployment guardian to a Laravel application. After each production release becomes reachable, an Artisan command asks the Website Security Analyzer to examine the public HTTPS endpoint. The integration maps the returned score, severity-grouped findings, TLS details, and recommendations into a strict domain object, then records a concise result for operators.
The analysis is bounded and non-invasive. It evaluates public HTTPS and browser security posture; it is not a penetration test and should never be described as one.
Get access before writing integration code
First, register an account, or sign in if you already have one.
Open the Website Security Analyzer service page. Choose an available Free, Plus, or Pro plan and complete its activation. Plan selection belongs here, rather than in application code, because quotas and commercial terms can change independently of a deployment.
Next, open the official service documentation. Find the Service token panel and copy the service-scoped token. Regenerating that token revokes the previously active token, so coordinate rotation with the corresponding production configuration update.
This service does require authentication; there is no unauthenticated call in this integration. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps the credential out of URLs, which are commonly retained in access logs and monitoring systems.
The exact operation is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Before building the Laravel feature, confirm access with a minimal request against a public HTTPS URL you control:
export SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
curl --request POST \
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website' \
--header "Authorization: Bearer ${SECURITY_ANALYZER_TOKEN}" \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"url":"https://www.example.com"}'
Do not commit the token or leave a real value in shell scripts. Put it in the production platform’s encrypted secret store and expose it to Laravel through environment-backed configuration:
# .env — use deployment secrets in production
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_ANALYZER_URL=https://www.example.com
SECURITY_ANALYZER_FAIL_ON=critical
<?php
// config/services.php
return [
// Existing services...
'website_security_analyzer' => [
'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'token' => env('SECURITY_ANALYZER_TOKEN'),
'url' => env('SECURITY_ANALYZER_URL'),
'fail_on' => env('SECURITY_ANALYZER_FAIL_ON'),
],
];
After changing production secrets, rebuild Laravel’s configuration cache. Application code must read config(), not call env() directly after configuration has been cached.
Choose a deliberately small architecture
The implementation needs PHP 8.3 or newer, a Laravel application, outbound HTTPS access to the analyzer, and a deployed public HTTPS URL. It uses Laravel’s built-in HTTP client, so no additional HTTP package is necessary.
The moving parts are intentionally limited:
- A domain object validates and carries the analyzer response.
- A service class owns authentication, timeouts, retries, and error classification.
- An Artisan command runs from the deployment pipeline and decides whether a configured severity should fail the step.
- HTTP fakes make the integration deterministic in automated tests.
A queued job would add delay and require a healthy worker precisely when a release is changing infrastructure. Here, the deployment process needs an immediate, bounded answer, so a synchronous command is the clearer trade-off. If the check later becomes informational rather than a release signal, dispatching equivalent work to a queue can be reasonable.
The relevant project structure is:
app/
Console/Commands/AnalyzeProductionWebsite.php
Domain/Security/AnalyzerException.php
Domain/Security/AnalyzerFailure.php
Domain/Security/WebsiteSecurityReport.php
Services/WebsiteSecurityAnalyzer.php
config/
services.php
tests/
Feature/WebsiteSecurityAnalyzerTest.php
Validate the API response at the boundary
Remote JSON is untrusted input even when it comes from a service you selected. The response mapper accepts only the supplied contract: a numeric score, an object of severity-grouped findings, an object of tls details, and a list of textual recommendations. It deliberately avoids guessing undocumented nested fields.
<?php
// app/Domain/Security/WebsiteSecurityReport.php
namespace App\Domain\Security;
use UnexpectedValueException;
final readonly class WebsiteSecurityReport
{
public function __construct(
public int|float $score,
public array $findingsBySeverity,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $payload): self
{
$score = $payload['score'] ?? null;
$findings = $payload['findings'] ?? null;
$tls = $payload['tls'] ?? null;
$recommendations = $payload['recommendations'] ?? null;
if (! is_int($score) && ! is_float($score)) {
throw new UnexpectedValueException('Analyzer score is not numeric.');
}
if (! is_array($findings) || array_is_list($findings)) {
throw new UnexpectedValueException('Analyzer findings are not severity-grouped.');
}
foreach ($findings as $severity => $items) {
if (! is_string($severity) || ! is_array($items) || ! array_is_list($items)) {
throw new UnexpectedValueException('A findings group is malformed.');
}
}
if (! is_array($tls) || array_is_list($tls)) {
throw new UnexpectedValueException('Analyzer TLS details are malformed.');
}
if (! is_array($recommendations) || ! array_is_list($recommendations)) {
throw new UnexpectedValueException('Analyzer recommendations are malformed.');
}
foreach ($recommendations as $recommendation) {
if (! is_string($recommendation)) {
throw new UnexpectedValueException('An analyzer recommendation is malformed.');
}
}
return new self($score, $findings, $tls, $recommendations);
}
public function findingCounts(): array
{
return array_map(
static fn (array $items): int => count($items),
$this->findingsBySeverity,
);
}
}
Structured failures let the command distinguish bad credentials from transient infrastructure trouble without parsing exception messages:
<?php
// app/Domain/Security/AnalyzerFailure.php
namespace App\Domain\Security;
enum AnalyzerFailure: string
{
case Configuration = 'configuration';
case Authentication = 'authentication';
case Validation = 'validation';
case RateLimited = 'rate_limited';
case RemoteService = 'remote_service';
case Network = 'network';
case MalformedResponse = 'malformed_response';
}
// app/Domain/Security/AnalyzerException.php
namespace App\Domain\Security;
use RuntimeException;
final class AnalyzerException extends RuntimeException
{
public function __construct(
public readonly AnalyzerFailure $failure,
string $message,
) {
parent::__construct($message);
}
}
Build a retry-aware Laravel HTTP client
The client allows three attempts, with bounded exponential backoff. It retries connection failures, HTTP 429 responses, and server errors. It does not blindly retry authentication or validation failures: another identical request will not repair an invalid token or body.
A numeric Retry-After value is respected for rate limiting, but capped at five seconds so the deployment cannot stall indefinitely. Connection and total response timeouts provide another hard boundary.
<?php
// app/Services/WebsiteSecurityAnalyzer.php
namespace App\Services;
use App\Domain\Security\AnalyzerException;
use App\Domain\Security\AnalyzerFailure;
use App\Domain\Security\WebsiteSecurityReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use UnexpectedValueException;
final class WebsiteSecurityAnalyzer
{
public function analyze(string $url): WebsiteSecurityReport
{
$endpoint = config('services.website_security_analyzer.endpoint');
$token = config('services.website_security_analyzer.token');
if (! is_string($endpoint) || $endpoint === '' ||
! is_string($token) || $token === '') {
throw new AnalyzerException(
AnalyzerFailure::Configuration,
'The analyzer endpoint or token is not configured.',
);
}
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
parse_url($url, PHP_URL_SCHEME) !== 'https' ||
! is_string(parse_url($url, PHP_URL_HOST))) {
throw new AnalyzerException(
AnalyzerFailure::Validation,
'The configured target must be a valid HTTPS URL.',
);
}
try {
$response = Http::acceptJson()
->asJson()
->withToken($token)
->connectTimeout(5)
->timeout(20)
->retry(
3,
function (int $attempt, \Exception $exception): int {
if ($exception instanceof RequestException &&
$exception->response->status() === 429) {
$header = $exception->response->header('Retry-After');
if (is_string($header) && ctype_digit($header)) {
return min(max((int) $header * 1000, 250), 5000);
}
}
return min(250 * (2 ** ($attempt - 1)), 2000);
},
function (\Exception $exception): bool {
return $exception instanceof ConnectionException ||
($exception instanceof RequestException && (
$exception->response->status() === 429 ||
$exception->response->serverError()
));
},
throw: false,
)
->post($endpoint, ['url' => $url]);
} catch (ConnectionException) {
throw new AnalyzerException(
AnalyzerFailure::Network,
'The analyzer could not be reached within the configured limits.',
);
}
$this->guardStatus($response);
$payload = $response->json();
if (! is_array($payload)) {
throw new AnalyzerException(
AnalyzerFailure::MalformedResponse,
'The analyzer returned non-object JSON.',
);
}
try {
return WebsiteSecurityReport::fromArray($payload);
} catch (UnexpectedValueException) {
throw new AnalyzerException(
AnalyzerFailure::MalformedResponse,
'The analyzer response did not match the expected contract.',
);
}
}
private function guardStatus(Response $response): void
{
if ($response->successful()) {
return;
}
$failure = match (true) {
in_array($response->status(), [401, 403], true) =>
AnalyzerFailure::Authentication,
in_array($response->status(), [400, 422], true) =>
AnalyzerFailure::Validation,
$response->status() === 429 =>
AnalyzerFailure::RateLimited,
default => AnalyzerFailure::RemoteService,
};
throw new AnalyzerException(
$failure,
'The analyzer request failed with HTTP '.$response->status().'.',
);
}
}
Expose the check as a deployment command
The command accepts no arbitrary URL. It analyzes only the operator-controlled environment value, preventing a CLI caller from turning the integration into a general URL-fetching facility. It also refuses to run outside production, which protects local test quotas from accidental use.
<?php
// app/Console/Commands/AnalyzeProductionWebsite.php
namespace App\Console\Commands;
use App\Domain\Security\AnalyzerException;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
final class AnalyzeProductionWebsite extends Command
{
protected $signature = 'security:analyze-production';
protected $description = 'Analyze the deployed public HTTPS website';
public function handle(WebsiteSecurityAnalyzer $analyzer): int
{
if (! app()->environment('production')) {
$this->error('This command runs only in production.');
return self::INVALID;
}
$url = config('services.website_security_analyzer.url');
if (! is_string($url) || $url === '') {
$this->error('SECURITY_ANALYZER_URL is not configured.');
return self::INVALID;
}
try {
$report = $analyzer->analyze($url);
} catch (AnalyzerException $exception) {
Log::error('Post-deployment security analysis failed.', [
'failure' => $exception->failure->value,
]);
$this->error(
'Security analysis failed: '.$exception->failure->value
);
return self::FAILURE;
}
$counts = $report->findingCounts();
Log::info('Post-deployment security analysis completed.', [
'url_host' => parse_url($url, PHP_URL_HOST),
'score' => $report->score,
'finding_counts' => $counts,
'recommendation_count' => count($report->recommendations),
]);
$this->info('Security score: '.$report->score);
foreach ($counts as $severity => $count) {
$this->line($severity.': '.$count);
}
$blockingSeverity = config(
'services.website_security_analyzer.fail_on'
);
if (is_string($blockingSeverity) &&
$blockingSeverity !== '' &&
($counts[$blockingSeverity] ?? 0) > 0) {
$this->error(
'Blocking findings detected for severity '.$blockingSeverity.'.'
);
return self::FAILURE;
}
return self::SUCCESS;
}
}
Laravel’s normal command discovery makes a class under app/Console/Commands available to Artisan. Confirm it with php artisan list before altering the production pipeline.
Test mapping, authentication, and failure behavior
Http::fake() prevents tests from consuming quota or depending on a network. The first test verifies the exact endpoint, Bearer header, request body, and domain mapping. The second proves that an authentication failure is not retried.
<?php
// tests/Feature/WebsiteSecurityAnalyzerTest.php
namespace Tests\Feature;
use App\Domain\Security\AnalyzerException;
use App\Domain\Security\AnalyzerFailure;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class WebsiteSecurityAnalyzerTest extends TestCase
{
private string $endpoint =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
protected function setUp(): void
{
parent::setUp();
config([
'services.website_security_analyzer.endpoint' => $this->endpoint,
'services.website_security_analyzer.token' => 'test-token',
]);
}
public function test_it_maps_a_successful_analysis(): void
{
Http::fake([
$this->endpoint => Http::response([
'score' => 91,
'findings' => [
'critical' => [],
'medium' => [['summary' => 'Example finding']],
],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported finding.'],
], 200),
]);
$report = app(WebsiteSecurityAnalyzer::class)
->analyze('https://www.example.com');
$this->assertSame(91, $report->score);
$this->assertCount(1, $report->findingsBySeverity['medium']);
$this->assertTrue($report->tls['enabled']);
Http::assertSent(fn (Request $request): bool =>
$request->url() === $this->endpoint &&
$request->method() === 'POST' &&
$request->hasHeader('Authorization', 'Bearer test-token') &&
$request['url'] === 'https://www.example.com'
);
}
public function test_it_does_not_retry_an_authentication_failure(): void
{
Http::fakeSequence()->pushStatus(401);
try {
app(WebsiteSecurityAnalyzer::class)
->analyze('https://www.example.com');
$this->fail('Expected AnalyzerException was not thrown.');
} catch (AnalyzerException $exception) {
$this->assertSame(
AnalyzerFailure::Authentication,
$exception->failure,
);
}
Http::assertSentCount(1);
}
}
Run the focused test with php artisan test --filter=WebsiteSecurityAnalyzerTest. Keep fake tokens in tests; a test suite should never need the production secret.
Wire it into the production deployment
Invoke the analyzer only after the release is live and its health endpoint succeeds. That ordering measures the public delivery path rather than an unreleased directory. A typical deployment tail looks like this:
php artisan migrate --force
php artisan config:cache
php artisan route:cache
curl --fail --silent --show-error \
'https://www.example.com/up' > /dev/null
php artisan security:analyze-production
The command’s nonzero exit status lets the deployment system surface analyzer outages, configuration errors, or findings at the configured blocking severity. Because the check occurs after traffic switches, do not assume a failing command automatically rolls back the release. Make rollback an explicit deployment-platform policy, and consider starting with alert-only behavior by leaving SECURITY_ANALYZER_FAIL_ON empty until the team understands its baseline.
Logs intentionally include the hostname, score, counts, and failure category, but not the token, response body, TLS payload, or finding contents. Send those structured events to the project’s existing log destination and alert on repeated failures or a meaningful score change. Avoid logging request headers when enabling HTTP diagnostics.
Common failures worth planning for
- 401 or 403: verify plan activation and the service-scoped token. If someone regenerated it, the old token is revoked and every environment using it must be updated.
- 400 or 422: confirm that
SECURITY_ANALYZER_URLis a complete, publicly reachable HTTPS URL. The client does not retry these responses. - 429: the bounded retry may recover from a brief limit, but repeated responses require reduced deployment frequency, quota review, or a different plan—not an infinite retry loop.
- Network or timeout failure: check outbound firewall and DNS access. Preserve the timeout rather than allowing deployments to hang.
- Malformed response: retain the failure category and HTTP status in telemetry, but do not weaken the mapper to accept arbitrary shapes. Review the official documentation before changing the boundary.
- Unexpected blocking: severity keys are taken from the response. Match
SECURITY_ANALYZER_FAIL_ONexactly to the severity your policy intends to block.
Final verification checklist
- The account and Free, Plus, or Pro plan are activated.
- The service-scoped token lives only in the production secret store.
- The target is a public HTTPS URL controlled by the project.
php artisan config:cacheruns after environment changes.- The HTTP fake tests pass without making external requests.
- The production health check succeeds before analysis begins.
- The deployment invokes
php artisan security:analyze-productionexactly once. - Logs show a score and severity counts without credentials or raw findings.
- Rate limits, remote failures, and the blocking-severity policy produce visible pipeline outcomes.
A production release is not finished when files reach a server; it is finished when the public site behaves as intended. By putting a bounded security-posture check immediately after deployment, Laravel gains a practical feedback loop at the point where configuration, TLS, proxies, and application responses finally meet. It does not replace deeper security testing, but it makes an important class of public regressions much harder to ship unnoticed.