Laravel Security Bot: Tjedna skeniranja web-mjesta i upozorenja vlasnicima
A weekly security check is easy to postpone until a certificate expires, a header disappears during deployment, or a configuration change quietly lowers a site’s protection. For a small business, the useful solution is not another dashboard someone must remember to visit. It is a narrow automation: scan the public website every week, preserve the result, and email the owner only when the score drops.
This tutorial builds that automation in Laravel using the Website Security Analyzer. The service performs bounded, non-invasive analysis of public HTTPS and browser security posture. It can identify configuration concerns, but its output must not be described as a penetration test, vulnerability assessment, or proof that a site is secure.
Get access and copy the service token
Start by creating 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 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 a Bearer token because query-string credentials can leak into access logs, browser history, and monitoring systems.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update the application secret, deploy or reload the application, verify one request, and only then consider the rotation complete.
Confirm the exact API request
The integration uses POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON body contains one required value, url. Before writing Laravel code, make a minimal request from a trusted terminal:
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-business.test"}'
Replace the example domain with the public HTTPS site you are authorized to monitor. A successful response supplies a score, findings grouped by severity, TLS details, and recommendations. We will validate those fields at the application boundary instead of assuming more about their internal structure.
Store the credential and monitoring settings in Laravel’s environment file. Never commit the populated file or copy its values into logs and fixtures.
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_SECURITY_URL=https://www.example-business.test
[email protected]
QUEUE_CONNECTION=database
Architecture and prerequisites
The project requires PHP 8.3 or newer, a maintained Laravel application, a database supported by Laravel, a configured mail transport, and cron access in production. The design has four focused parts:
- A client sends the bounded API request and maps the response into a domain object.
- An Artisan command records every successful or failed scan.
- Laravel’s scheduler invokes the command weekly and prevents overlapping execution.
- A queued job sends an alert when the new score is lower than the previous successful score.
Persisting snapshots is more reliable than keeping the last score in a cache: cache eviction should not reset the business baseline. Queueing email separates scan completion from mail-provider latency and gives delivery failures controlled retries.
If the database queue tables are not already present, create them, then run all migrations:
php artisan queue:table
php artisan make:migration create_website_security_snapshots_table
php artisan migrate
Configure the service boundary
Add a dedicated entry to config/services.php. The endpoint remains fixed in source configuration, while secrets and business-specific values come from the environment.
'website_security_analyzer' => [
'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'token' => env('WEBSITE_SECURITY_TOKEN'),
'website_url' => env('WEBSITE_SECURITY_URL'),
'owner_email' => env('WEBSITE_SECURITY_OWNER_EMAIL'),
],
Create app/Domain/Security/WebsiteSecurityReport.php. The mapper requires only the documented top-level contract and leaves nested finding and TLS data intact. That avoids inventing undocumented subfields.
<?php
namespace App\Domain\Security;
use UnexpectedValueException;
final readonly class WebsiteSecurityReport
{
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $data): self
{
if (!array_key_exists('score', $data) || !is_numeric($data['score'])) {
throw new UnexpectedValueException('Response score is missing or invalid.');
}
foreach (['findings', 'tls', 'recommendations'] as $field) {
if (!array_key_exists($field, $data) || !is_array($data[$field])) {
throw new UnexpectedValueException(
"Response {$field} field is missing or invalid."
);
}
}
foreach ($data['findings'] as $severity => $findings) {
if (!is_string($severity) || !is_array($findings)) {
throw new UnexpectedValueException(
'Findings must be grouped into severity arrays.'
);
}
}
return new self(
score: (float) $data['score'],
findingsBySeverity: $data['findings'],
tls: $data['tls'],
recommendations: $data['recommendations'],
);
}
}
Add bounded retries and explicit failure states
Create app/Services/WebsiteSecurityAnalyzer.php. It uses Laravel’s built-in HTTP client with a five-second connection timeout and a twenty-second response timeout. Only connection failures, rate limits, and server failures are retried. Authentication and validation failures return immediately because repeating the same request will not repair them.
<?php
namespace App\Services;
use App\Domain\Security\WebsiteSecurityReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
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
{
public function analyze(string $url): WebsiteSecurityReport
{
$token = config('services.website_security_analyzer.token');
$endpoint = config('services.website_security_analyzer.endpoint');
if (!is_string($token) || $token === '') {
throw new AnalyzerException('configuration', '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(
'transport',
'Analyzer connection failed after bounded retries.',
previous: $exception,
);
}
Log::warning('Website analyzer connection retry', [
'attempt' => $attempt,
'website_url' => $url,
]);
usleep($attempt * 1_000_000);
continue;
}
if ($response->successful()) {
try {
return WebsiteSecurityReport::fromArray($response->json());
} catch (Throwable $exception) {
throw new AnalyzerException(
'invalid_response',
'Analyzer returned an unexpected response shape.',
$response->status(),
$exception,
);
}
}
$status = $response->status();
if (in_array($status, [401, 403], true)) {
throw new AnalyzerException(
'authentication',
'Analyzer rejected the service token.',
$status,
);
}
$retryable = $status === 429 || $status >= 500;
if (!$retryable || $attempt === 3) {
$kind = $status === 429
? 'rate_limited'
: ($status >= 500 ? 'upstream' : 'request_rejected');
throw new AnalyzerException(
$kind,
'Analyzer request failed.',
$status,
);
}
$retryAfter = $response->header('Retry-After');
$seconds = filter_var($retryAfter, FILTER_VALIDATE_INT);
$delay = $seconds === false ? $attempt : max(1, min($seconds, 10));
Log::warning('Website analyzer response retry', [
'attempt' => $attempt,
'status' => $status,
'delay_seconds' => $delay,
'website_url' => $url,
]);
usleep($delay * 1_000_000);
}
throw new AnalyzerException('internal', 'Analyzer loop ended unexpectedly.');
}
}
The response body is deliberately absent from failure logs. Status, attempt, and target URL are enough for operations without copying arbitrary upstream content or credentials into centralized logging.
Persist scans and detect a real score drop
Define the migration so successful snapshots retain all four contract areas, while failed attempts retain a structured failure category. JSON columns preserve nested data without pretending undocumented elements have a stable relational schema.
<?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_snapshots', function (Blueprint $table) {
$table->id();
$table->string('website_url');
$table->string('status')->index();
$table->decimal('score', 10, 2)->nullable();
$table->decimal('previous_score', 10, 2)->nullable();
$table->json('findings')->nullable();
$table->json('tls')->nullable();
$table->json('recommendations')->nullable();
$table->boolean('alert_required')->default(false);
$table->timestamp('alerted_at')->nullable();
$table->string('error_kind')->nullable();
$table->text('error_message')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('website_security_snapshots');
}
};
Create the corresponding model at app/Models/WebsiteSecuritySnapshot.php with normal mass-assignment protection and casts:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class WebsiteSecuritySnapshot extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'score' => 'float',
'previous_score' => 'float',
'findings' => 'array',
'tls' => 'array',
'recommendations' => 'array',
'alert_required' => 'boolean',
'alerted_at' => 'datetime',
];
}
}
Now create app/Console/Commands/ScanWebsiteSecurity.php. Failed scans are recorded but never become the comparison baseline. That distinction prevents a temporary outage from erasing the last known good score.
<?php
namespace App\Console\Commands;
use App\Jobs\SendSecurityDropAlert;
use App\Models\WebsiteSecuritySnapshot;
use App\Services\AnalyzerException;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
final class ScanWebsiteSecurity extends Command
{
protected $signature = 'security:scan-website';
protected $description = 'Scan the configured website and alert on a score drop';
public function handle(WebsiteSecurityAnalyzer $analyzer): int
{
$url = config('services.website_security_analyzer.website_url');
if (!is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) {
$this->error('WEBSITE_SECURITY_URL is missing or invalid.');
return self::FAILURE;
}
$previous = WebsiteSecuritySnapshot::query()
->where('status', 'success')
->latest('id')
->first();
try {
$report = $analyzer->analyze($url);
} catch (AnalyzerException $exception) {
WebsiteSecuritySnapshot::create([
'website_url' => $url,
'status' => 'failed',
'error_kind' => $exception->kind,
'error_message' => Str::limit($exception->getMessage(), 1000),
]);
report($exception);
$this->error("Scan failed: {$exception->kind}");
return self::FAILURE;
}
$dropped = $previous !== null && $report->score < $previous->score;
$snapshot = WebsiteSecuritySnapshot::create([
'website_url' => $url,
'status' => 'success',
'score' => $report->score,
'previous_score' => $previous?->score,
'findings' => $report->findingsBySeverity,
'tls' => $report->tls,
'recommendations' => $report->recommendations,
'alert_required' => $dropped,
]);
if ($dropped) {
SendSecurityDropAlert::dispatch($snapshot->id);
}
$this->info("Security scan stored with score {$report->score}.");
return self::SUCCESS;
}
}
Queue the owner alert
Create app/Jobs/SendSecurityDropAlert.php. The job checks its persisted state before sending, retries transient mail failures with increasing delays, and records successful delivery. Queue processing is generally at-least-once, so an SMTP acceptance followed by a database failure can still produce a duplicate; the stable subject and persisted alert state make that rare case recognizable.
<?php
namespace App\Jobs;
use App\Models\WebsiteSecuritySnapshot;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;
use LogicException;
final class SendSecurityDropAlert implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function __construct(public readonly int $snapshotId) {}
public function backoff(): array
{
return [60, 300, 900, 3600];
}
public function handle(): void
{
$snapshot = WebsiteSecuritySnapshot::findOrFail($this->snapshotId);
if (!$snapshot->alert_required || $snapshot->alerted_at !== null) {
return;
}
$recipient = config('services.website_security_analyzer.owner_email');
if (!is_string($recipient) || filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) {
throw new LogicException('Security alert recipient is not configured.');
}
$body = implode("\n", [
"The website security score dropped.",
"Website: {$snapshot->website_url}",
"Previous score: {$snapshot->previous_score}",
"Current score: {$snapshot->score}",
'',
'Severity-grouped findings:',
json_encode($snapshot->findings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
'',
'TLS details:',
json_encode($snapshot->tls, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
'',
'Recommendations:',
json_encode($snapshot->recommendations, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
'',
'This is a bounded public HTTPS and browser-posture analysis, not a penetration test.',
]);
Mail::raw($body, function (Message $message) use ($recipient, $snapshot) {
$message
->to($recipient)
->subject(
"Website security score dropped: {$snapshot->previous_score} to {$snapshot->score}"
);
});
$snapshot->update(['alerted_at' => now()]);
}
}
Schedule it weekly
In a current Laravel application, add the schedule to routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('security:scan-website')
->weekly()
->mondays()
->at('08:00')
->timezone(config('app.timezone'))
->withoutOverlapping(120)
->onOneServer();
onOneServer() is valuable when multiple application instances run the scheduler, but it requires a shared cache backend that supports atomic locks. Production also needs one cron entry invoking php artisan schedule:run every minute and a supervised queue worker, for example:
php artisan queue:work --tries=5 --timeout=60
php artisan schedule:list
php artisan security:scan-website
Do not run the manual scan repeatedly against a metered plan. Check plan limits, alert on HTTP 429 responses, monitor failed jobs, and prune historical snapshots according to the business’s retention needs.
Test the boundary and drop behavior
Laravel’s Http::fake() makes the external boundary deterministic. The first test verifies response mapping. The second proves that a lower score queues an alert. No real token, network request, or email is used.
<?php
namespace Tests\Feature;
use App\Jobs\SendSecurityDropAlert;
use App\Models\WebsiteSecuritySnapshot;
use App\Services\AnalyzerException;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class WebsiteSecurityMonitorTest extends TestCase
{
use RefreshDatabase;
private string $endpoint =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
config()->set('services.website_security_analyzer.token', 'test-token');
config()->set('services.website_security_analyzer.endpoint', $this->endpoint);
config()->set(
'services.website_security_analyzer.website_url',
'https://business.test'
);
config()->set(
'services.website_security_analyzer.owner_email',
'[email protected]'
);
}
public function test_it_maps_a_successful_report(): void
{
Http::fake([
$this->endpoint => Http::response([
'score' => 91,
'findings' => ['high' => [], 'medium' => []],
'tls' => ['enabled' => true],
'recommendations' => [],
], 200),
]);
$report = app(WebsiteSecurityAnalyzer::class)
->analyze('https://business.test');
$this->assertSame(91.0, $report->score);
$this->assertArrayHasKey('high', $report->findingsBySeverity);
Http::assertSentCount(1);
Http::assertSent(fn ($request) =>
$request->url() === $this->endpoint
&& $request['url'] === 'https://business.test'
&& $request->hasHeader('Authorization', 'Bearer test-token')
);
}
public function test_a_score_drop_queues_an_owner_alert(): void
{
Queue::fake();
WebsiteSecuritySnapshot::create([
'website_url' => 'https://business.test',
'status' => 'success',
'score' => 91,
'findings' => [],
'tls' => [],
'recommendations' => [],
]);
Http::fake([
$this->endpoint => Http::response([
'score' => 84,
'findings' => ['high' => [['check' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => [['action' => 'review configuration']],
], 200),
]);
$this->artisan('security:scan-website')->assertSuccessful();
$snapshot = WebsiteSecuritySnapshot::latest('id')->firstOrFail();
$this->assertTrue($snapshot->alert_required);
$this->assertSame(91.0, $snapshot->previous_score);
$this->assertSame(84.0, $snapshot->score);
Queue::assertPushed(
SendSecurityDropAlert::class,
fn ($job) => $job->snapshotId === $snapshot->id
);
}
public function test_authentication_failure_is_not_retried(): void
{
Http::fake([
$this->endpoint => Http::response([], 401),
]);
try {
app(WebsiteSecurityAnalyzer::class)
->analyze('https://business.test');
$this->fail('Expected an AnalyzerException.');
} catch (AnalyzerException $exception) {
$this->assertSame('authentication', $exception->kind);
}
Http::assertSentCount(1);
}
}
Common production failures
- 401 or 403: verify the service-scoped token and check whether someone regenerated it. Do not retry these responses blindly.
- 422 or another client rejection: confirm that the configured target is a valid, publicly reachable HTTPS URL. Record the failure, then correct the input.
- 429: the client honors a numeric
Retry-Aftervalue within a ten-second ceiling. Persistent rate limiting should fail visibly rather than occupy a worker indefinitely. - Unexpected response shape: treat it as an integration failure. Do not silently replace missing fields with empty arrays and send a misleading “successful” report.
- No email: inspect the queue worker, failed-jobs store, mail transport, and recipient configuration. A scheduled command can succeed while delivery remains queued.
- Duplicate scans: confirm the scheduler’s shared lock backend and ensure only intended hosts run
schedule:run.
Final verification checklist
- Confirm the token exists only in environment-backed secret configuration.
- Run configuration caching after deployment with
php artisan config:cache. - Run the test suite and a single authorized manual scan.
- Inspect the stored snapshot without logging the token or full HTTP headers.
- Create a controlled test baseline with a higher score and verify that a lower fake response queues the alert.
- Confirm cron, the queue worker, failed-job monitoring, mail delivery, and database backups.
- Document that results describe bounded public posture analysis, not a penetration test.
The important engineering choice is not the Monday morning schedule. It is preserving meaning across failures: an unavailable API is not a zero score, an invalid response is not a clean report, and a queued email is not yet a delivered alert. Once those distinctions are explicit, a small weekly integration becomes dependable enough to protect something genuinely valuable: the owner’s attention.