Laravel: Автоматизирајте неделни безбедносни скенирања на веб-страници и известувајте ги сопствениците
A small business website can drift into a weaker security posture without anyone deploying an obviously dangerous change. A certificate configuration changes, a proxy stops sending an important browser header, or a hosting migration quietly alters HTTPS behavior. The useful response is not another dashboard that someone must remember to open. It is a weekly check with a durable baseline and an email only when the score drops.
This tutorial builds that workflow in Laravel on PHP 8.3. A scheduled Artisan command calls the Website Security Analyzer, validates its response at the application boundary, compares the result with the previous successful score, and emails the site owner when the new score is lower.
The analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. Its output is useful for monitoring and prioritization, but it must not be described as a penetration test or treated as a substitute for one.
Get access before writing integration code
- Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
- Open the Website Security Analyzer service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token shown there.
This service is not tokenless: every request requires authentication using a Bearer token, an X-API-Token header, or a token query parameter. We will use the standard Bearer form. Regenerating the service token revokes the previously active token, so coordinate rotation with deployment rather than regenerating it casually.
Confirm the exact API call
The integration sends POST requests to https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. The JSON body contains one required value, url.
curl --request POST \
--url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example-business.test"}'
Use a public HTTPS URL that you own or are authorized to monitor. Never place the real token in shell history shared with other users, documentation, screenshots, fixtures, or source control.
Put credentials in environment-backed configuration
Add the deployment-specific values to .env. Use a real public domain in the deployed application.
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_MONITOR_URL=https://www.example-business.test
[email protected]
Add the API settings to config/services.php:
'security_analyzer' => [
'endpoint' => env(
'SECURITY_ANALYZER_ENDPOINT',
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
),
'token' => env('SECURITY_ANALYZER_TOKEN'),
],
Create config/security-monitor.php for the monitored site:
<?php
return [
'url' => env('SECURITY_MONITOR_URL'),
'owner_email' => env('SECURITY_MONITOR_OWNER'),
];
Configuration files may be committed because they contain only environment lookups. The populated .env file must remain outside version control.
Choose a deliberately small architecture
The application needs four components: an API client, a domain response object, a database record holding the last successful score, and a scheduled command. The command sends mail synchronously because this project scans one site once a week. Introducing a queue would add workers, retry multiplication, and more deployment state without improving this modest workload.
The relevant project structure is:
app/Services/SecurityAnalyzer.phpapp/Data/SecurityReport.phpapp/Exceptions/AnalyzerException.phpapp/Models/WebsiteMonitor.phpapp/Console/Commands/ScanWebsiteSecurity.phpapp/Mail/SecurityScoreDropped.phpresources/views/mail/security-score-dropped.blade.phproutes/console.php
Generate the framework-owned pieces, then edit them as shown below.
php artisan make:model WebsiteMonitor -m
php artisan make:command ScanWebsiteSecurity
php artisan make:mail SecurityScoreDropped --markdown=mail.security-score-dropped
Persist the last successful result
The baseline belongs in the database, not a process-local variable or ephemeral cache. Use a stable key so changing the URL does not accidentally create a second monitor.
<?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_monitors', function (Blueprint $table): void {
$table->id();
$table->string('key', 100)->unique();
$table->text('url');
$table->string('owner_email');
$table->decimal('last_score', 8, 2)->nullable();
$table->timestamp('last_checked_at')->nullable();
$table->string('last_status', 30)->nullable();
$table->string('last_error', 100)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('website_monitors');
}
};
Allow those fields in WebsiteMonitor and cast the stored values:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class WebsiteMonitor extends Model
{
protected $fillable = [
'key', 'url', 'owner_email', 'last_score',
'last_checked_at', 'last_status', 'last_error',
];
protected function casts(): array
{
return [
'last_score' => 'float',
'last_checked_at' => 'immutable_datetime',
];
}
}
Validate the analyzer response at the boundary
Remote JSON is untrusted input even when it comes from a service you selected. The contract supplies a score, severity-grouped findings, TLS details, and recommendations. Map those values once, rejecting malformed responses before they reach business logic.
<?php
namespace App\Data;
use UnexpectedValueException;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tls,
public array $recommendations,
) {}
public static function fromPayload(array $payload): self
{
foreach (['score', 'findings', 'tls', 'recommendations'] as $field) {
if (! array_key_exists($field, $payload)) {
throw new UnexpectedValueException("Missing response field: {$field}");
}
}
if (! is_int($payload['score']) && ! is_float($payload['score'])) {
throw new UnexpectedValueException('The score must be numeric.');
}
if (! is_array($payload['findings'])
|| ! is_array($payload['tls'])
|| ! is_array($payload['recommendations'])) {
throw new UnexpectedValueException('Invalid analyzer response shape.');
}
foreach ($payload['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) $payload['score'],
findingsBySeverity: $payload['findings'],
tls: $payload['tls'],
recommendations: $payload['recommendations'],
);
}
}
Build a bounded, retry-aware HTTP client
The client uses Laravel’s built-in HTTP facade with separate connection and total-response timeouts. It retries connection failures, rate limits, and server failures only. Authentication and validation failures are deterministic and should not consume more quota through blind retries.
<?php
namespace App\Services;
use App\Data\SecurityReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
use UnexpectedValueException;
final class SecurityAnalyzer
{
public function analyze(string $url): SecurityReport
{
$token = config('services.security_analyzer.token');
$endpoint = config('services.security_analyzer.endpoint');
if (! is_string($token) || $token === '') {
throw new RuntimeException('Security analyzer token is not configured.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->withToken($token)
->connectTimeout(5)
->timeout(20)
->post($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new RuntimeException(
'Security analyzer connection failed.',
previous: $exception
);
}
usleep(250000 * $attempt);
continue;
}
if ($response->status() === 429 || $response->serverError()) {
if ($attempt === 3) {
throw new RuntimeException(
$response->status() === 429
? 'Security analyzer quota or rate limit reached.'
: 'Security analyzer is temporarily unavailable.'
);
}
$retryAfter = $response->header('Retry-After');
$milliseconds = is_numeric($retryAfter)
? min(5000, max(0, (int) $retryAfter * 1000))
: 250 * $attempt;
Log::warning('Security analyzer request will be retried.', [
'attempt' => $attempt,
'status' => $response->status(),
]);
usleep($milliseconds * 1000);
continue;
}
if (in_array($response->status(), [401, 403], true)) {
throw new RuntimeException(
'Security analyzer authentication was rejected.'
);
}
if ($response->status() === 422) {
throw new RuntimeException(
'Security analyzer rejected the website URL.'
);
}
if ($response->failed()) {
throw new RuntimeException(
"Security analyzer returned HTTP {$response->status()}."
);
}
$payload = $response->json();
if (! is_array($payload)) {
throw new RuntimeException('Security analyzer returned invalid JSON.');
}
try {
return SecurityReport::fromPayload($payload);
} catch (UnexpectedValueException $exception) {
throw new RuntimeException(
'Security analyzer returned an unexpected response.',
previous: $exception
);
}
}
throw new RuntimeException('Security analyzer request did not complete.');
}
}
The client deliberately does not include response bodies, tokens, or authorization headers in logs. Its retry delay honors a numeric Retry-After value while capping the pause at five seconds, preventing one scheduled invocation from sleeping indefinitely.
Compare scores and alert the owner
The first successful scan establishes the baseline without sending an alarming “drop” email. Later scans send mail only when the new score is strictly lower. The baseline is saved after successful mail delivery, so a mail transport failure does not silently consume the alert.
<?php
namespace App\Console\Commands;
use App\Mail\SecurityScoreDropped;
use App\Models\WebsiteMonitor;
use App\Services\SecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class ScanWebsiteSecurity extends Command
{
protected $signature = 'security:scan-weekly';
protected $description = 'Analyze the configured website and alert on a score drop';
public function handle(SecurityAnalyzer $analyzer): int
{
$url = config('security-monitor.url');
$owner = config('security-monitor.owner_email');
if (! is_string($url) || ! filter_var($url, FILTER_VALIDATE_URL)
|| ! str_starts_with(strtolower($url), 'https://')
|| ! is_string($owner) || ! filter_var($owner, FILTER_VALIDATE_EMAIL)) {
$this->error('Monitor URL or owner email is invalid.');
return self::FAILURE;
}
$monitor = WebsiteMonitor::updateOrCreate(
['key' => 'primary'],
['url' => $url, 'owner_email' => $owner]
);
try {
$report = $analyzer->analyze($url);
$previous = $monitor->last_score;
if ($previous !== null && $report->score < $previous) {
$findingCount = array_sum(
array_map('count', $report->findingsBySeverity)
);
Mail::to($owner)->send(new SecurityScoreDropped(
siteUrl: $url,
previousScore: $previous,
currentScore: $report->score,
findingCount: $findingCount,
));
}
$monitor->update([
'last_score' => $report->score,
'last_checked_at' => now(),
'last_status' => 'ok',
'last_error' => null,
]);
$this->info("Security score recorded: {$report->score}");
return self::SUCCESS;
} catch (Throwable $exception) {
$monitor->update([
'last_status' => 'failed',
'last_error' => 'scan_or_delivery_failed',
]);
Log::error('Weekly website security check failed.', [
'monitor_id' => $monitor->id,
'exception_type' => $exception::class,
]);
$this->error('Security check failed; inspect application logs.');
return self::FAILURE;
}
}
}
The mailable keeps the email focused on the decision the owner must make. Detailed findings, TLS data, and recommendations remain available to application code without turning ordinary email into a repository of security information.
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class SecurityScoreDropped extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $siteUrl,
public float $previousScore,
public float $currentScore,
public int $findingCount,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Website security score dropped');
}
public function content(): Content
{
return new Content(markdown: 'mail.security-score-dropped');
}
public function attachments(): array
{
return [];
}
}
Replace the generated Markdown mail view with:
@component('mail::message')
# Website security score dropped
The weekly check for {{ $siteUrl }} reported a lower score.
Previous score: {{ $previousScore }}
Current score: {{ $currentScore }}
Reported findings: {{ $findingCount }}
Review the analyzer results and verify important changes before altering production.
Thanks,
{{ config('app.name') }}
@endcomponent
Schedule the weekly run
In routes/console.php, schedule Monday morning in the application timezone and prevent overlapping executions:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('security:scan-weekly')
->weeklyOn(1, '08:00')
->timezone(config('app.timezone'))
->withoutOverlapping(120);
If the application scheduler runs on several servers, add onOneServer() and configure a shared cache driver that supports atomic locks. On one server, the normal Laravel scheduler cron entry is sufficient:
* * * * * cd /var/www/example-app && php artisan schedule:run >> /dev/null 2>&1
Test behavior without calling the real service
Http::fake() makes the test deterministic, while Mail::fake() proves that a score drop produces the intended notification.
<?php
namespace Tests\Feature;
use App\Mail\SecurityScoreDropped;
use App\Models\WebsiteMonitor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
final class WeeklySecurityScanTest extends TestCase
{
use RefreshDatabase;
public function test_it_emails_the_owner_when_the_score_drops(): void
{
config([
'security-monitor.url' => 'https://shop.example.test',
'security-monitor.owner_email' => '[email protected]',
'services.security_analyzer.token' => 'test-token',
]);
WebsiteMonitor::create([
'key' => 'primary',
'url' => 'https://shop.example.test',
'owner_email' => '[email protected]',
'last_score' => 92,
]);
Http::fake([
'https://ai.mihajlo.mk/*' => Http::response([
'score' => 81,
'findings' => [
'high' => [['message' => 'Example finding']],
'medium' => [],
'low' => [],
],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported finding.'],
], 200),
]);
Mail::fake();
$this->artisan('security:scan-weekly')->assertSuccessful();
$this->assertDatabaseHas('website_monitors', [
'key' => 'primary',
'last_score' => 81,
'last_status' => 'ok',
]);
Mail::assertSent(
SecurityScoreDropped::class,
fn (SecurityScoreDropped $mail): bool =>
$mail->hasTo('[email protected]')
&& $mail->previousScore === 92.0
&& $mail->currentScore === 81.0
);
Http::assertSent(fn ($request): bool =>
$request->method() === 'POST'
&& $request['url'] === 'https://shop.example.test'
&& $request->hasHeader('Authorization', 'Bearer test-token')
);
}
}
Add companion tests for the first-run baseline, an unchanged score, malformed JSON, HTTP 401, HTTP 422, rate limiting, and exhausted server-error retries. Authentication and validation tests should assert that exactly one request was made.
Production security and operations
Run php artisan migrate --force during deployment, configure a real mail transport, set APP_TIMEZONE deliberately, and run php artisan config:cache only after the environment values are present. After token rotation, update the deployed secret and rebuild the configuration cache because the previous token stops working.
Monitor both the scheduler and the command. A healthy application log should identify success through the stored timestamp and failures through structured messages, without recording response bodies or credentials. Alert operationally when last_checked_at becomes older than the expected weekly interval; otherwise a broken cron process can look like an uneventful week.
Common failure patterns are straightforward: a 401 or 403 usually means the token is missing, revoked, or belongs to the wrong service; a 422 points to the submitted URL; a 429 requires plan or scheduling review; repeated 5xx responses indicate a temporary upstream failure; and successful scans with no email usually mean the score did not drop or the first run merely established its baseline.
Final verification checklist
- The monitored address is a public HTTPS URL you are authorized to check.
- The service-scoped token exists only in environment-backed secret storage.
- The migration has run and the configured mail transport can deliver externally.
php artisan security:scan-weeklycompletes successfully by hand.- A fake lower score sends one email and records the new baseline.
- Authentication and validation failures are not retried.
- Rate limits, connection failures, and server failures stop after bounded retries.
- The system cron invokes Laravel’s scheduler every minute.
- Logs and monitoring reveal missed scans without exposing credentials.
The most valuable part of this integration is not the weekly API call. It is the discipline around it: a validated boundary, a persistent baseline, restrained retries, and a notification tied to meaningful change. That turns a security score from another number in another dashboard into a quiet operational signal the business owner can actually act on.