Laravel Security Watch: Weekly Website Audits & Owner Alerts with AI
A website can remain online while its security posture quietly gets worse. A certificate change, proxy update, removed header, or hurried deployment may not break a page, yet it can lower the protection visitors receive.
This tutorial builds a Laravel security watch for that quieter class of failure. Once a week, an Artisan command submits a small business website to the Website Security Analyzer, validates and stores the result, compares its score with the previous successful check, and queues an email when the score drops.
The analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. Its output is useful operational evidence, but it is not a penetration test and should never be presented as one.
Prerequisites and design
You need PHP 8.3 or newer, a Laravel application, a supported database, working SMTP configuration, and a queue worker. The website must be publicly reachable over HTTPS.
The design deliberately stays small:
- An Artisan command owns the weekly workflow.
- A dedicated client isolates the external API contract.
- A DTO rejects malformed responses at the boundary.
- One database row holds the latest successful baseline.
- A queued job sends the email, allowing delivery retries without repeating the analysis.
The first successful run establishes a baseline and sends no alert. Later runs alert only when the new numeric score is lower. Findings grouped by severity, TLS details, and recommendations are retained with the score for diagnosis.
Get access to the analyzer
- 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 the 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.
This service is not tokenless. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form so the credential remains out of the URL and ordinary access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a coordinated deployment: update the application secret, rebuild cached configuration, verify one request, and then confirm scheduled runs are healthy.
Verify the exact endpoint
The API call is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. It receives a JSON body containing url. Make one minimal request before building the scheduled feature:
curl --fail-with-body \
--request POST \
--url 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-business.test"}'
Replace the example domain with the real public HTTPS site. Do not paste the token into source control, shell scripts, screenshots, logs, or test fixtures.
Store it in the deployed environment. Add the following placeholders to the local .env file and provide equivalent secrets through your production platform:
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_ANALYZER_URL=https://www.example-business.test
[email protected]
QUEUE_CONNECTION=database
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.test
MAIL_PORT=587
MAIL_USERNAME=YOUR_SMTP_USERNAME
MAIL_PASSWORD=YOUR_SMTP_PASSWORD
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Website Security Watch"
Add this entry inside the array returned by config/services.php:
'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'),
'owner_email' => env('SECURITY_OWNER_EMAIL'),
],
Application code should read config(), never call env() directly. That distinction matters after Laravel configuration is cached.
Create the state store
The project-specific files will be compact:
app/
Console/Commands/AuditWebsiteSecurity.php
Data/SecurityReport.php
Exceptions/AnalyzerException.php
Jobs/SendScoreDropAlert.php
Models/WebsiteSecurityCheck.php
Notifications/ScoreDropped.php
Services/WebsiteSecurityAnalyzer.php
database/migrations/..._create_website_security_checks_table.php
tests/Feature/AuditWebsiteSecurityTest.php
tests/Unit/WebsiteSecurityAnalyzerTest.php
Create the migration and model with Artisan, then define the table:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('website_security_checks', function (Blueprint $table) {
$table->id();
$table->string('target_url')->unique();
$table->decimal('last_score', 10, 2)->nullable();
$table->json('latest_report')->nullable();
$table->timestamp('checked_at')->nullable();
$table->string('last_error')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('website_security_checks');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class WebsiteSecurityCheck extends Model
{
protected $fillable = [
'target_url',
'last_score',
'latest_report',
'checked_at',
'last_error',
'last_error_at',
];
protected function casts(): array
{
return [
'last_score' => 'decimal:2',
'latest_report' => 'array',
'checked_at' => 'datetime',
'last_error_at' => 'datetime',
];
}
}
Defend the application boundary
External JSON is untrusted input, even when it comes from a service you operate deliberately. The DTO below accepts the contract’s score, severity-grouped findings, TLS details, and recommendations, while refusing missing or structurally invalid data.
<?php
namespace App\Data;
use App\Exceptions\AnalyzerException;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $payload): self
{
foreach (['score', 'findings', 'tls', 'recommendations'] as $field) {
if (! array_key_exists($field, $payload)) {
throw new AnalyzerException("Analyzer response is missing {$field}.");
}
}
$score = filter_var($payload['score'], FILTER_VALIDATE_FLOAT);
if ($score === false) {
throw new AnalyzerException('Analyzer score is not numeric.');
}
if (! is_array($payload['findings'])
|| ! is_array($payload['tls'])
|| ! is_array($payload['recommendations'])) {
throw new AnalyzerException('Analyzer response has an invalid structure.');
}
foreach ($payload['findings'] as $severity => $items) {
if (! is_string($severity) || ! is_array($items)) {
throw new AnalyzerException('Findings are not grouped by severity.');
}
}
return new self(
(float) $score,
$payload['findings'],
$payload['tls'],
$payload['recommendations'],
);
}
public function toArray(): array
{
return [
'score' => $this->score,
'findings' => $this->findings,
'tls' => $this->tls,
'recommendations' => $this->recommendations,
];
}
}
Keep contract translation in this one class. If the official documentation changes the response envelope, the rest of the application should not need to understand that transport detail.
Build a bounded HTTP client
The client uses Laravel’s built-in HTTP client, with a five-second connection timeout and a twenty-second total response timeout. It retries connection failures, HTTP 408, HTTP 429, and server failures. Authentication, permission, validation, and other client errors are not blindly retried.
<?php
namespace App\Exceptions;
use RuntimeException;
final class AnalyzerException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
<?php
namespace App\Services;
use App\Data\SecurityReport;
use App\Exceptions\AnalyzerException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
final class WebsiteSecurityAnalyzer
{
public function analyze(string $url): SecurityReport
{
$token = (string) config('services.security_analyzer.token');
$endpoint = (string) config('services.security_analyzer.endpoint');
if ($token === '') {
throw new AnalyzerException('Analyzer token is not configured.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->acceptJson()
->asJson()
->connectTimeout(5)
->timeout(20)
->post($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new AnalyzerException(
'Analyzer connection failed after retries.'
);
}
usleep(250000 * (2 ** ($attempt - 1)));
continue;
}
if ($response->successful()) {
$json = $response->json();
if (! is_array($json)) {
throw new AnalyzerException('Analyzer returned invalid JSON.');
}
return SecurityReport::fromArray($json);
}
$status = $response->status();
$retryable = $status === 408
|| $status === 429
|| $status >= 500;
if (! $retryable || $attempt === 3) {
throw new AnalyzerException(
"Analyzer request failed with HTTP {$status}.",
$status,
);
}
$this->pause($response, $attempt);
}
throw new AnalyzerException('Analyzer request did not complete.');
}
private function pause(Response $response, int $attempt): void
{
$header = (string) $response->header('Retry-After');
$seconds = ctype_digit($header) ? min((int) $header, 5) : 0;
if ($seconds > 0) {
sleep($seconds);
return;
}
usleep(250000 * (2 ** ($attempt - 1)));
}
}
The capped Retry-After wait prevents a weekly command from occupying a worker indefinitely. A persistent 429 remains a visible failure and should prompt a plan or scheduling review. Response bodies and tokens are intentionally absent from exceptions and logs.
Compare scores and queue the alert
The command validates that the configured target is HTTPS, performs the remote request before opening a database transaction, and locks the baseline row only during comparison and persistence.
<?php
namespace App\Console\Commands;
use App\Exceptions\AnalyzerException;
use App\Jobs\SendScoreDropAlert;
use App\Models\WebsiteSecurityCheck;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
final class AuditWebsiteSecurity extends Command
{
protected $signature = 'security:audit';
protected $description = 'Audit the configured website security posture';
public function handle(WebsiteSecurityAnalyzer $analyzer): int
{
$url = (string) config('services.security_analyzer.url');
if (! filter_var($url, FILTER_VALIDATE_URL)
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
$this->error('SECURITY_ANALYZER_URL must be a valid HTTPS URL.');
return self::FAILURE;
}
try {
$report = $analyzer->analyze($url);
$previous = DB::transaction(function () use ($url, $report) {
$state = WebsiteSecurityCheck::query()
->where('target_url', $url)
->lockForUpdate()
->first();
$previous = $state?->last_score;
$state ??= new WebsiteSecurityCheck(['target_url' => $url]);
$state->fill([
'last_score' => $report->score,
'latest_report' => $report->toArray(),
'checked_at' => now(),
'last_error' => null,
'last_error_at' => null,
])->save();
return $previous === null ? null : (float) $previous;
});
if ($previous !== null && $report->score < $previous) {
SendScoreDropAlert::dispatch(
(string) config('services.security_analyzer.owner_email'),
$url,
$previous,
$report->toArray(),
);
}
Log::info('Website security audit completed', [
'host' => parse_url($url, PHP_URL_HOST),
'score' => $report->score,
'previous_score' => $previous,
'alert_queued' => $previous !== null
&& $report->score < $previous,
]);
return self::SUCCESS;
} catch (Throwable $exception) {
$message = $exception instanceof AnalyzerException
? $exception->getMessage()
: 'Unexpected security audit failure.';
WebsiteSecurityCheck::query()->updateOrCreate(
['target_url' => $url],
['last_error' => $message, 'last_error_at' => now()],
);
Log::error('Website security audit failed', [
'host' => parse_url($url, PHP_URL_HOST),
'exception' => $exception::class,
'status' => $exception instanceof AnalyzerException
? $exception->status
: null,
]);
return self::FAILURE;
}
}
}
Use a queued job because SMTP is a separate failure domain. The saved baseline should not be rolled back merely because the mail server is temporarily unavailable.
<?php
namespace App\Jobs;
use App\Notifications\ScoreDropped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Notification;
final class SendScoreDropAlert implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function __construct(
public string $ownerEmail,
public string $url,
public float $previousScore,
public array $report,
) {}
public function backoff(): array
{
return [60, 300, 900, 3600];
}
public function handle(): void
{
Notification::route('mail', $this->ownerEmail)->notify(
new ScoreDropped(
$this->url,
$this->previousScore,
$this->report,
)
);
}
}
<?php
namespace App\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
final class ScoreDropped extends Notification
{
public function __construct(
private string $url,
private float $previousScore,
private array $report,
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$mail = (new MailMessage)
->subject('Website security score dropped')
->line("The security score fell from {$this->previousScore} to {$this->report['score']}.");
foreach ($this->report['findings'] as $severity => $items) {
$mail->line(ucfirst($severity).': '.count($items).' finding(s)');
}
foreach (array_filter(
$this->report['recommendations'],
'is_string'
) as $recommendation) {
$mail->line($recommendation);
}
return $mail
->action('Review the website', $this->url)
->line('This is a bounded posture analysis, not a penetration test.');
}
}
Queue delivery is normally at least once, so an unusual worker failure after SMTP acceptance can produce a duplicate email. That is preferable to silently losing a meaningful alert; add an alert identifier and provider-level idempotency only if duplicate suppression becomes important.
Schedule the weekly run
Add the schedule to routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('security:audit')
->weekly()
->withoutOverlapping(30)
->onOneServer();
withoutOverlapping() protects against a slow previous run. onOneServer() prevents duplicate execution across multiple application nodes and requires a shared, supported cache store. Run php artisan security:audit manually to establish the first baseline.
Test success and failure paths
Laravel’s Http::fake() makes these tests deterministic: no real token, network request, or owner email is involved.
<?php
namespace Tests\Feature;
use App\Jobs\SendScoreDropAlert;
use App\Models\WebsiteSecurityCheck;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class AuditWebsiteSecurityTest extends TestCase
{
use RefreshDatabase;
public function test_it_queues_an_alert_when_the_score_drops(): void
{
config()->set('services.security_analyzer.token', 'test-token');
config()->set('services.security_analyzer.url', 'https://shop.test');
WebsiteSecurityCheck::create([
'target_url' => 'https://shop.test',
'last_score' => 90,
]);
Http::fake([
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
=> Http::response([
'score' => 82,
'findings' => ['high' => [['type' => 'header']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported high finding.'],
]),
]);
Queue::fake();
$this->assertSame(0, Artisan::call('security:audit'));
Queue::assertPushed(
SendScoreDropAlert::class,
fn (SendScoreDropAlert $job) =>
$job->previousScore === 90.0
&& $job->report['score'] === 82.0
);
Http::assertSent(fn ($request) =>
$request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://shop.test'
);
}
public function test_first_successful_run_only_sets_the_baseline(): void
{
config()->set('services.security_analyzer.token', 'test-token');
config()->set('services.security_analyzer.url', 'https://shop.test');
Http::fake([ '*' => Http::response([
'score' => 88,
'findings' => [],
'tls' => [],
'recommendations' => [],
]) ]);
Queue::fake();
$this->assertSame(0, Artisan::call('security:audit'));
Queue::assertNothingPushed();
}
}
Add a unit test that returns HTTP 401 and call the client directly. Assert that exactly one request was sent and that AnalyzerException was raised. That protects the important policy that invalid credentials are never retried. A separate fake sequence of HTTP 500 followed by success can verify the transient retry path.
Production security and deployment
- Keep the target URL environment-controlled. Do not expose an endpoint that lets arbitrary users submit URLs.
- Grant deployment and runtime identities access only to the service token they need.
- Do not log authorization headers, full response bodies, SMTP credentials, or token-bearing URLs.
- Restrict access to stored reports because findings may reveal defensive weaknesses.
- Use a shared cache for scheduler locks and a durable queue driver in multi-node deployments.
- Monitor command failures, stale
checked_atvalues, failed queue jobs, and repeated HTTP 429 responses.
Deploy the code and environment secrets, then run:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan security:audit
php artisan schedule:list
If the application lacks a jobs-table migration, generate the framework’s queue-table migration before migrating. Keep a supervised php artisan queue:work --tries=5 --timeout=60 process running. Configure cron to execute php artisan schedule:run every minute; Laravel decides when the weekly task is due.
Common failures
- HTTP 401 or 403: confirm plan activation and the service-scoped token. A regenerated token immediately invalidates the previous active token.
- HTTP 422: verify that the JSON contains
urland that the configured site is a public HTTPS URL. Correct the request instead of retrying it. - HTTP 429: inspect scheduling frequency and plan limits. Short retries may absorb a transient limit, but persistent quota exhaustion needs an operational change.
- No weekly execution: check cron,
schedule:list, timezone expectations, shared cache connectivity, and stale overlap locks. - No email: inspect failed jobs and worker logs, then test SMTP independently. Ensure a queue worker is actually consuming the configured connection.
- Malformed response: compare the official documentation with
SecurityReport::fromArray(). Update only the boundary mapper if the documented response shape changes.
Final verification checklist
- The token exists only in environment-backed configuration.
- The minimal POST request succeeds against the exact analyzer endpoint.
- The first Artisan run stores a baseline without emailing the owner.
- A lower fake score queues one alert containing the previous and current scores.
- Authentication and validation failures are not retried.
- Transient connection, rate-limit, and server failures use bounded backoff.
- The scheduler has shared locking, cron is active, and the queue worker is supervised.
- Logs identify outcomes without exposing credentials or full security reports.
A weekly security score is not a substitute for patching, code review, dependency management, incident response, or professional security testing. Its value is narrower and wonderfully practical: it turns silent posture drift into a visible event. With a defensively mapped API boundary, a durable baseline, and a retryable mail path, a small business gains an early warning system that is modest enough to maintain and dependable enough to matter.