Laravel: Контролна табла за безбедност на клиентски сајтови со историски AI ревизорски траги
A security score is useful once. A security history is useful every Monday morning, after every release, and whenever a client asks whether an issue was actually fixed.
This tutorial builds a production-oriented Laravel dashboard for a small agency. Each client site can be submitted for bounded, non-invasive analysis of its public HTTPS and browser security posture. The application queues the work, preserves historical results, groups findings by severity, exposes TLS details, and turns recommendations into practical remediation tasks.
This is posture monitoring, not a penetration test. It does not prove that a site is secure, authorize intrusive testing, or replace manual review by a qualified security professional.
Get access and copy the service token
First, register an account, or use the sign-in page if you already have one.
- Open the Website Security Analyzer service page.
- Choose the available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy its service-scoped token.
Regenerating that token revokes the previously active token, so treat rotation as a deployment change: update every environment that uses it before restarting workers. This service requires authentication. It supports 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 access logs and monitoring systems.
Verify the exact endpoint
The integration uses POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website with a JSON body containing url. Test it before writing application code:
curl --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://example.com"}'
Keep the real token out of shell history where practical. Never commit it, place it in a test fixture, or include it in an exception report.
Store it in the Laravel environment before building the feature:
MIHAJLO_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
MIHAJLO_SECURITY_ANALYZER_ENDPOINT=https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Add the environment-backed configuration to config/services.php:
'website_security_analyzer' => [
'endpoint' => env(
'MIHAJLO_SECURITY_ANALYZER_ENDPOINT',
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
),
'token' => env('MIHAJLO_SECURITY_ANALYZER_TOKEN'),
],
Choose an architecture that preserves history
Calling the analyzer directly from a browser request would couple page latency to an external service. Instead, the controller records a queued audit immediately, and a Laravel queue job performs the analysis. The dashboard reads only local data.
The resulting path is deliberately small:
SecurityAuditControllervalidates submissions and lists audit history.RunSecurityAuditowns background execution and rate-limit rescheduling.WebsiteSecurityAnalyzeris the sole HTTP boundary.SecurityReportvalidates and maps external data into the domain.SecurityAuditstores status, score, findings, TLS details, recommendations, and safe failure information.
This design costs one queue worker, but gives users responsive requests, explicit failure states, controlled retries, and durable evidence of improvement.
Scaffold the Laravel feature
You need PHP 8.3 or later, Composer, a supported database, and a Laravel application with authentication. A database-backed queue is sufficient for a small agency.
composer create-project laravel/laravel agency-security-dashboard
cd agency-security-dashboard
php artisan make:model SecurityAudit -m
php artisan make:controller SecurityAuditController
php artisan make:job RunSecurityAudit
php artisan make:queue-table
php artisan migrate
Define the audit migration so every attempt remains independently inspectable:
<?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('security_audits', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('client_name');
$table->string('url', 2048);
$table->string('status', 20)->default('queued');
$table->decimal('score', 8, 2)->nullable();
$table->json('findings')->nullable();
$table->json('tls_details')->nullable();
$table->json('recommendations')->nullable();
$table->string('error_code')->nullable();
$table->string('error_message')->nullable();
$table->timestamps();
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('security_audits');
}
};
In app/Models/SecurityAudit.php, make JSON results native arrays:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class SecurityAudit extends Model
{
protected $fillable = [
'user_id', 'client_name', 'url', 'status', 'score',
'findings', 'tls_details', 'recommendations',
'error_code', 'error_message',
];
protected function casts(): array
{
return [
'score' => 'float',
'findings' => 'array',
'tls_details' => 'array',
'recommendations' => 'array',
];
}
}
Validate the API response at the boundary
External JSON must not be trusted merely because the HTTP status is successful. The documented concepts are a score, severity-grouped findings, TLS details, and recommendations. Their contents may evolve, so validate the top-level contract while preserving nested data.
<?php
// app/Data/SecurityReport.php
namespace App\Data;
use App\Exceptions\AnalyzerException;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tlsDetails,
public array $recommendations,
) {}
public static function fromArray(array $data): self
{
if (!isset($data['score']) || !is_numeric($data['score'])) {
throw new AnalyzerException('malformed_response', 'Missing numeric score.');
}
$findings = $data['findings'] ?? null;
$tls = $data['tls'] ?? null;
$recommendations = $data['recommendations'] ?? null;
if (!is_array($findings) || !is_array($tls) || !is_array($recommendations)) {
throw new AnalyzerException(
'malformed_response',
'Findings, TLS details, or recommendations are invalid.'
);
}
$grouped = [];
foreach ($findings as $severity => $items) {
if (!is_string($severity) || !is_array($items)) {
throw new AnalyzerException(
'malformed_response',
'Findings are not grouped by severity.'
);
}
$grouped[strtolower($severity)] = array_values($items);
}
return new self(
score: (float) $data['score'],
findingsBySeverity: $grouped,
tlsDetails: $tls,
recommendations: array_values($recommendations),
);
}
}
Create the exception referenced above in app/Exceptions/AnalyzerException.php:
<?php
namespace App\Exceptions;
use RuntimeException;
final class AnalyzerException extends RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $retryAfter = null,
) {
parent::__construct($message);
}
}
Build a bounded, failure-aware HTTP client
The service retries connection failures and server errors twice with exponential backoff. It does not blindly retry authentication failures, validation errors, or rate limits. A rate-limited response is handed to the queue job, where delaying work does not occupy a PHP process.
<?php
// app/Services/WebsiteSecurityAnalyzer.php
namespace App\Services;
use App\Data\SecurityReport;
use App\Exceptions\AnalyzerException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
final class WebsiteSecurityAnalyzer
{
public function analyze(string $url): SecurityReport
{
$endpoint = (string) config('services.website_security_analyzer.endpoint');
$token = (string) config('services.website_security_analyzer.token');
if ($token === '') {
throw new AnalyzerException('configuration', 'Service token is not configured.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->asJson()
->withToken($token)
->connectTimeout(3)
->timeout(15)
->post($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt < 3) {
usleep(250_000 * (2 ** ($attempt - 1)));
continue;
}
throw new AnalyzerException('network', 'Analyzer connection failed.');
}
if ($response->serverError() && $attempt < 3) {
usleep(250_000 * (2 ** ($attempt - 1)));
continue;
}
if (in_array($response->status(), [401, 403], true)) {
throw new AnalyzerException('authentication', 'Analyzer authentication failed.');
}
if ($response->status() === 422) {
throw new AnalyzerException('validation', 'The analyzer rejected the URL.');
}
if ($response->status() === 429) {
$header = $response->header('Retry-After');
$seconds = is_string($header) && ctype_digit($header)
? (int) $header
: null;
throw new AnalyzerException(
'rate_limited',
'Analyzer rate limit reached.',
$seconds
);
}
if (!$response->successful()) {
throw new AnalyzerException(
'upstream_http',
'Analyzer returned HTTP '.$response->status().'.'
);
}
$payload = $response->json();
if (!is_array($payload)) {
throw new AnalyzerException('malformed_response', 'Analyzer returned invalid JSON.');
}
return SecurityReport::fromArray($payload);
}
throw new AnalyzerException('unavailable', 'Analyzer is unavailable.');
}
}
Run audits safely in the queue
The job stores normalized results and logs identifiers rather than response bodies or tokens. Rate limits are delayed between 30 seconds and 15 minutes; persistent rate limiting becomes a visible failed audit after four queue attempts.
<?php
// app/Jobs/RunSecurityAudit.php
namespace App\Jobs;
use App\Exceptions\AnalyzerException;
use App\Models\SecurityAudit;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
final class RunSecurityAudit implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 4;
public int $timeout = 70;
public function __construct(public readonly int $auditId) {}
public function handle(WebsiteSecurityAnalyzer $analyzer): void
{
$audit = SecurityAudit::find($this->auditId);
if (!$audit || $audit->status === 'complete') {
return;
}
$audit->update(['status' => 'running']);
try {
$report = $analyzer->analyze($audit->url);
$audit->update([
'status' => 'complete',
'score' => $report->score,
'findings' => $report->findingsBySeverity,
'tls_details' => $report->tlsDetails,
'recommendations' => $report->recommendations,
'error_code' => null,
'error_message' => null,
]);
Log::info('Security audit completed', [
'audit_id' => $audit->id,
'score' => $report->score,
]);
} catch (AnalyzerException $exception) {
if ($exception->kind === 'rate_limited' && $this->attempts() < $this->tries) {
$audit->update([
'status' => 'queued',
'error_code' => 'rate_limited',
'error_message' => 'Waiting for analyzer capacity.',
]);
$delay = min(max($exception->retryAfter ?? 60, 30), 900);
$this->release($delay);
return;
}
$audit->update([
'status' => 'failed',
'error_code' => $exception->kind,
'error_message' => $exception->getMessage(),
]);
Log::warning('Security audit failed', [
'audit_id' => $audit->id,
'failure' => $exception->kind,
]);
}
}
}
Connect the dashboard
The application sends requests only to the configured analyzer endpoint, but submitted URLs still deserve tight validation. Accept public HTTPS hostnames, reject credentials and raw IP addresses, and avoid sending URLs containing sensitive query parameters.
<?php
// app/Http/Controllers/SecurityAuditController.php
namespace App\Http\Controllers;
use App\Jobs\RunSecurityAudit;
use App\Models\SecurityAudit;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class SecurityAuditController extends Controller
{
public function index(Request $request): View
{
$audits = SecurityAudit::query()
->where('user_id', $request->user()->id)
->latest()
->paginate(20);
return view('security.index', compact('audits'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'client_name' => ['required', 'string', 'max:120'],
'url' => [
'required',
'string',
'max:2048',
function (string $attribute, mixed $value, Closure $fail): void {
$parts = is_string($value) ? parse_url($value) : false;
$host = is_array($parts) ? ($parts['host'] ?? null) : null;
if (
!is_array($parts) ||
strtolower((string) ($parts['scheme'] ?? '')) !== 'https' ||
!is_string($host) ||
isset($parts['user'], $parts['pass']) ||
strtolower($host) === 'localhost' ||
filter_var($host, FILTER_VALIDATE_IP)
) {
$fail('Enter a public HTTPS URL using a hostname.');
}
},
],
]);
$audit = SecurityAudit::create([
'user_id' => $request->user()->id,
'client_name' => $validated['client_name'],
'url' => $validated['url'],
'status' => 'queued',
]);
RunSecurityAudit::dispatch($audit->id);
return to_route('security.index')->with('status', 'Audit queued.');
}
}
Register authenticated routes in routes/web.php:
use App\Http\Controllers\SecurityAuditController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function (): void {
Route::get('/security', [SecurityAuditController::class, 'index'])
->name('security.index');
Route::post('/security', [SecurityAuditController::class, 'store'])
->name('security.store');
});
The Blade view should render status and score prominently, then present findings by severity, TLS data, and each recommendation as a remediation task. Use escaped Blade output such as {{ $value }}; never render analyzer content with unescaped {!! !!}. Each task can begin as “open,” with ownership and completion fields added later without changing the API boundary.
Test successful and rejected calls deterministically
Laravel’s HTTP fake verifies the exact method, endpoint, authentication, JSON body, and mapping without contacting the service:
<?php
// tests/Feature/WebsiteSecurityAnalyzerTest.php
namespace Tests\Feature;
use App\Exceptions\AnalyzerException;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class WebsiteSecurityAnalyzerTest extends TestCase
{
public function test_it_maps_a_security_report(): void
{
$endpoint = 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
config()->set('services.website_security_analyzer', [
'endpoint' => $endpoint,
'token' => 'test-token',
]);
Http::fake([
$endpoint => Http::response([
'score' => 82,
'findings' => ['high' => [], 'medium' => [['issue' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported medium-severity issue.'],
], 200),
]);
$report = app(WebsiteSecurityAnalyzer::class)->analyze('https://example.com');
$this->assertSame(82.0, $report->score);
$this->assertCount(1, $report->findingsBySeverity['medium']);
Http::assertSent(fn ($request) =>
$request->method() === 'POST' &&
$request->url() === $endpoint &&
$request->hasHeader('Authorization', 'Bearer test-token') &&
$request['url'] === 'https://example.com'
);
}
public function test_it_does_not_retry_authentication_failures(): void
{
config()->set('services.website_security_analyzer', [
'endpoint' => 'https://example.test/analyze',
'token' => 'invalid-token',
]);
Http::fake([
'https://example.test/analyze' => Http::response([], 401),
]);
try {
app(WebsiteSecurityAnalyzer::class)->analyze('https://example.com');
$this->fail('Expected authentication failure.');
} catch (AnalyzerException $exception) {
$this->assertSame('authentication', $exception->kind);
}
Http::assertSentCount(1);
}
}
Add controller tests for cross-user isolation and validation, plus a job test asserting that a completed audit receives all four mapped result areas. Run the suite with php artisan test.
Security, observability, and deployment
Place the dashboard behind authentication and enforce authorization by agency account or client ownership. The example scopes reads to the current user; a shared-team product should use an explicit policy and tenant identifier rather than widening that query.
Do not log tokens, authorization headers, complete upstream bodies, or submitted query strings. Findings may reveal weak configuration, so restrict database access, encrypt backups, define a retention period, and record who starts an audit. Alert on rising counts of authentication, rate_limited, network, and malformed_response failures.
Deploy migrations before workers, cache configuration only after the environment is present, and keep the queue visibility timeout longer than the worker timeout:
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:restart
php artisan queue:work --tries=4 --timeout=75
For a database or Redis queue, configure retry_after above 75 seconds, such as 120 seconds, so another worker does not pick up an audit that is still running. Use a process supervisor to restart workers and allow more than 75 seconds for graceful shutdown.
Common failures worth designing for
- Every audit reports authentication failure: confirm the service-scoped token, plan activation, and cached configuration. If the token was regenerated, the old value is revoked.
- Audits remain queued: verify the queue connection, worker process, and failed-jobs storage.
- Frequent rate limits: respect delayed retries and reduce scheduling frequency or review the active plan. Do not create a tight retry loop.
- Malformed response failures: retain the safe failure code, inspect the current official documentation, and update the boundary mapper deliberately.
- Duplicate historical rows: disable repeated form submissions in the interface or add an idempotency rule based on user, normalized URL, and a short submission window.
Final verification checklist
- The token exists only in environment-backed configuration.
- The client posts exactly one
urlvalue to the documented endpoint. - Connection and response timeouts are bounded.
- Only network and server failures receive immediate backoff retries.
- Authentication and validation failures are not retried.
- Rate limits become delayed queue work and eventually a visible failure.
- Score, severity-grouped findings, TLS details, and recommendations persist historically.
- Users can see only audits they are authorized to access.
- Tests use
Http::fake()and never consume service quota. - The interface labels results as non-invasive posture analysis, not a penetration test.
A durable audit trail changes the conversation from “Is this site secure?” to the more useful questions: “What changed, what matters most, and who will fix it?” That is exactly where a small agency dashboard earns its place—not by manufacturing certainty, but by making evidence, history, and the next practical action unmistakably clear.