Laravel Dashboard: Empower Clients with Actionable Website Security Insights
A security report is only useful when someone can act on it. For a small agency, that means turning a technical snapshot into a durable client record: what changed, what matters, who should fix it, and whether the next scan improved the score.
This tutorial builds that workflow in Laravel using the Website Security Analyzer. Each analysis runs in the background, stores its history, preserves severity-grouped findings and TLS details, and converts recommendations into remediation tasks. The result is deliberately framed as a bounded review of public HTTPS and browser security posture, not a penetration test or proof that a site is secure.
Get access and copy the service token
- 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 its service-scoped token.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token because query parameters are more likely to appear in proxy, browser, and access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update the secret store, restart workers so they reload configuration, verify a scan, and only then consider the rotation complete.
Confirm the endpoint before writing Laravel code
The exact operation is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Its JSON request contains url. Test it with a public HTTPS site you are authorized to assess:
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://client.example"}'
Do not commit the token. Put it in the project environment and expose it through Laravel configuration:
# .env
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_SECURITY_CONNECT_TIMEOUT=5
WEBSITE_SECURITY_TIMEOUT=25
<?php
// config/services.php
return [
// Other services...
'website_security' => [
'base_url' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api',
'token' => env('WEBSITE_SECURITY_TOKEN'),
'connect_timeout' => (int) env('WEBSITE_SECURITY_CONNECT_TIMEOUT', 5),
'timeout' => (int) env('WEBSITE_SECURITY_TIMEOUT', 25),
],
];
Choose a small, resilient architecture
Assume the agency already has authenticated users and a Client model. We will add a security scan, generated remediation tasks, an API adapter, a response mapper, a queued job, and a controller.
- The controller validates and authorizes a request, creates a pending scan, and dispatches a job.
- The job invokes the analyzer outside the web request, so a slow upstream response does not hold open the dashboard request.
- The adapter owns authentication, timeouts, retry policy, and HTTP failure classification.
- The mapper treats the remote response as untrusted data and produces a stable domain object.
- The database keeps immutable scan results while tasks remain editable operational records.
A database-backed queue is sufficient for a small agency. It avoids adding infrastructure merely for appearance; a dedicated queue service can replace it later without changing the job contract.
app/
Data/SecurityAnalysis.php
Exceptions/AnalyzerException.php
Jobs/AnalyzeWebsite.php
Services/WebsiteSecurityAnalyzer.php
Http/Controllers/ClientSecurityController.php
resources/views/clients/security.blade.php
tests/Feature/WebsiteSecurityAnalyzerTest.php
Persist history and remediation work
Create migrations for scans and tasks. JSON columns preserve the analyzer’s nested findings and TLS details without coupling database columns to every response detail.
<?php
// database/migrations/..._create_security_scans_and_tasks.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_scans', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->string('url', 2048);
$table->string('status', 32)->default('pending');
$table->decimal('score', 10, 2)->nullable();
$table->json('findings')->nullable();
$table->json('tls_details')->nullable();
$table->json('recommendations')->nullable();
$table->string('failure_code', 64)->nullable();
$table->text('failure_message')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['client_id', 'created_at']);
});
Schema::create('remediation_tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('security_scan_id')
->constrained()
->cascadeOnDelete();
$table->text('description');
$table->string('status', 24)->default('open');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('remediation_tasks');
Schema::dropIfExists('security_scans');
}
};
The corresponding models should cast findings, tls_details, and recommendations to array, score to decimal:2, and completed_at to datetime. Add Client::securityScans(), SecurityScan::client(), and SecurityScan::remediationTasks() relationships.
Map the response at one defensive boundary
The service contract gives us a score, severity-grouped findings, TLS details, and recommendations. Do not let controllers or Blade templates traverse raw HTTP data. Validate the expected fields once and fail explicitly if the service returns an incompatible document.
<?php
// app/Data/SecurityAnalysis.php
namespace App\Data;
use App\Exceptions\AnalyzerException;
final readonly class SecurityAnalysis
{
public function __construct(
public float $score,
public array $findings,
public array $tlsDetails,
public array $recommendations,
) {}
public static function fromArray(array $payload): self
{
$score = $payload['score'] ?? null;
$findings = $payload['findings'] ?? null;
$tls = $payload['tls_details'] ?? null;
$recommendations = $payload['recommendations'] ?? null;
if (! is_numeric($score)
|| ! is_array($findings)
|| ! is_array($tls)
|| ! is_array($recommendations)) {
throw new AnalyzerException(
'invalid_response',
'The analyzer returned an incompatible response.'
);
}
foreach ($findings as $severity => $items) {
if (! is_string($severity) || ! is_array($items)) {
throw new AnalyzerException(
'invalid_response',
'Findings were not grouped by severity.'
);
}
}
return new self(
score: (float) $score,
findings: $findings,
tlsDetails: $tls,
recommendations: $recommendations,
);
}
public function taskDescriptions(): array
{
return array_values(array_filter(array_map(
static function (mixed $item): ?string {
if (is_string($item)) {
return trim($item) !== '' ? trim($item) : null;
}
if (is_array($item) && is_string($item['description'] ?? null)) {
$description = trim($item['description']);
return $description !== '' ? $description : null;
}
return null;
},
$this->recommendations
)));
}
}
AnalyzerException is a small application exception carrying a public failure code and a safe message. It prevents upstream HTML, stack traces, or token-bearing request details from leaking into the dashboard.
Build the HTTP adapter with bounded retries
Retries should be selective. A connection failure or transient 5xx response may succeed on another attempt. Authentication and validation failures will not. A 429 response should preserve a rate-limit state rather than launching an aggressive retry storm.
<?php
// app/Services/WebsiteSecurityAnalyzer.php
namespace App\Services;
use App\Data\SecurityAnalysis;
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): SecurityAnalysis
{
$token = config('services.website_security.token');
if (! is_string($token) || $token === '') {
throw new AnalyzerException(
'configuration',
'The analyzer is not configured.'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::baseUrl(
config('services.website_security.base_url')
)
->withToken($token)
->acceptJson()
->asJson()
->connectTimeout(
config('services.website_security.connect_timeout')
)
->timeout(config('services.website_security.timeout'))
->post('/v1/analyze-website', ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new AnalyzerException(
'connection',
'The analyzer could not be reached.',
previous: $exception
);
}
usleep(200_000 * $attempt);
continue;
}
if ($response->successful()) {
$json = $response->json();
if (! is_array($json)) {
throw new AnalyzerException(
'invalid_response',
'The analyzer returned invalid JSON.'
);
}
return SecurityAnalysis::fromArray($json);
}
if (in_array($response->status(), [401, 403], true)) {
throw new AnalyzerException(
'authentication',
'Analyzer authentication failed.'
);
}
if ($response->status() === 422) {
throw new AnalyzerException(
'validation',
'The submitted URL was not accepted.'
);
}
if ($response->status() === 429) {
throw new AnalyzerException(
'rate_limited',
'The analyzer quota or rate limit was reached.'
);
}
if ($response->serverError() && $attempt < 3) {
usleep(200_000 * $attempt);
continue;
}
throw new AnalyzerException(
'upstream',
'The analyzer returned an unexpected error.'
);
}
throw new AnalyzerException('upstream', 'Analysis did not complete.');
}
}
The backoff is intentionally short and bounded. Longer recovery belongs in queue scheduling, where it does not occupy a web worker. Never log the token, Authorization header, raw response body, or full request payload.
Run scans in an idempotent queue job
The job exits unless the scan remains pending, preventing accidental duplicate execution after double dispatch. It records a structured failure state and creates tasks only after a valid analysis has been mapped.
<?php
// app/Jobs/AnalyzeWebsite.php
namespace App\Jobs;
use App\Exceptions\AnalyzerException;
use App\Models\SecurityScan;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
final class AnalyzeWebsite implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 90;
public function __construct(public int $scanId) {}
public function handle(WebsiteSecurityAnalyzer $analyzer): void
{
$scan = SecurityScan::findOrFail($this->scanId);
if ($scan->status !== 'pending') {
return;
}
try {
$result = $analyzer->analyze($scan->url);
DB::transaction(function () use ($scan, $result): void {
$scan->update([
'status' => 'completed',
'score' => $result->score,
'findings' => $result->findings,
'tls_details' => $result->tlsDetails,
'recommendations' => $result->recommendations,
'completed_at' => now(),
]);
foreach ($result->taskDescriptions() as $description) {
$scan->remediationTasks()->create([
'description' => $description,
'status' => 'open',
]);
}
});
Log::info('Website security scan completed', [
'scan_id' => $scan->id,
'client_id' => $scan->client_id,
]);
} catch (AnalyzerException $exception) {
$scan->update([
'status' => 'failed',
'failure_code' => $exception->failureCode,
'failure_message' => $exception->getMessage(),
'completed_at' => now(),
]);
Log::warning('Website security scan failed', [
'scan_id' => $scan->id,
'client_id' => $scan->client_id,
'failure_code' => $exception->failureCode,
]);
}
}
}
Configure the queue with QUEUE_CONNECTION=database, ensure its jobs table exists for your Laravel installation, run migrations, and start a worker with php artisan queue:work --queue=default --tries=1 --timeout=90.
Connect the dashboard
The controller must authorize access to the client before exposing history. Accept only HTTPS URLs and reject local hosts or literal private addresses before dispatch. The analyzer is intended for public HTTPS posture, not internal network discovery.
<?php
// app/Http/Controllers/ClientSecurityController.php
namespace App\Http\Controllers;
use App\Jobs\AnalyzeWebsite;
use App\Models\Client;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class ClientSecurityController extends Controller
{
public function index(Client $client): View
{
$this->authorize('view', $client);
return view('clients.security', [
'client' => $client,
'scans' => $client->securityScans()
->with('remediationTasks')
->latest()
->paginate(20),
]);
}
public function store(Request $request, Client $client): RedirectResponse
{
$this->authorize('update', $client);
$validated = $request->validate([
'url' => ['required', 'url:http,https', 'starts_with:https://', 'max:2048'],
]);
$host = parse_url($validated['url'], PHP_URL_HOST);
$blocked = ! is_string($host)
|| strtolower($host) === 'localhost'
|| (filter_var($host, FILTER_VALIDATE_IP)
&& ! filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
));
abort_if($blocked, 422, 'A public HTTPS URL is required.');
$scan = $client->securityScans()->create([
'url' => $validated['url'],
'status' => 'pending',
]);
AnalyzeWebsite::dispatch($scan->id);
return back()->with('status', 'Security analysis queued.');
}
}
<?php
// routes/web.php
use App\Http\Controllers\ClientSecurityController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function (): void {
Route::get('/clients/{client}/security', [
ClientSecurityController::class, 'index',
])->name('clients.security.index');
Route::post('/clients/{client}/security', [
ClientSecurityController::class, 'store',
])->middleware('throttle:10,1')
->name('clients.security.store');
});
The Blade view should show the latest score prominently, label pending and failed runs clearly, group findings by their returned severity keys, summarize TLS details, and list remediation tasks with status controls. Render all service-originated values through escaped Blade expressions such as {{ $description }}; never use unescaped {!! !!} output.
Add a visible note beside every result: “This is a bounded, non-invasive review of public HTTPS and browser security posture. It is not a penetration test.” That distinction is technically honest and helps clients interpret the dashboard correctly.
Test the boundary without calling production
Laravel’s HTTP fake makes the test deterministic and verifies that the credential and request body are correct without placing a real token in fixtures.
<?php
// tests/Feature/WebsiteSecurityAnalyzerTest.php
namespace Tests\Feature;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class WebsiteSecurityAnalyzerTest extends TestCase
{
public function test_it_maps_a_successful_analysis(): void
{
config([
'services.website_security.base_url' =>
'https://ai.mihajlo.mk/api/website-security-analyzer-api',
'services.website_security.token' => 'test-token',
]);
Http::fake([
'*/v1/analyze-website' => Http::response([
'score' => 82,
'findings' => [
'high' => [['summary' => 'Example finding']],
'low' => [],
],
'tls_details' => ['enabled' => true],
'recommendations' => [
['description' => 'Review the reported finding.'],
],
], 200),
]);
$result = app(WebsiteSecurityAnalyzer::class)
->analyze('https://client.example');
$this->assertSame(82.0, $result->score);
$this->assertCount(1, $result->taskDescriptions());
Http::assertSent(fn ($request) =>
$request->method() === 'POST'
&& $request->url() ===
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
&& $request['url'] === 'https://client.example'
&& $request->hasHeader('Authorization', 'Bearer test-token')
);
}
public function test_it_does_not_retry_authentication_failures(): void
{
config(['services.website_security.token' => 'invalid']);
Http::fake(fn () => Http::response([], 401));
$this->expectException(\App\Exceptions\AnalyzerException::class);
try {
app(WebsiteSecurityAnalyzer::class)
->analyze('https://client.example');
} finally {
Http::assertSentCount(1);
}
}
}
Add job tests for completed persistence, generated tasks, and failed status handling. Add controller tests proving unauthenticated and unauthorized users cannot view or create scans, and that HTTP, localhost, and private-IP URLs are rejected.
Deploy and operate it deliberately
In production, inject WEBSITE_SECURITY_TOKEN through the hosting platform’s secret manager. Run php artisan config:cache only after the environment is present, then restart long-running queue workers with php artisan queue:restart. A worker started before token rotation keeps its old configuration until restarted.
Monitor completed, failed, authentication, rate-limited, and invalid-response counts. Alert on sustained failures rather than a single transient error. Keep application logs free of credentials and raw response bodies, and apply an appropriate retention policy to scan history because findings can reveal weaknesses a client has not yet repaired.
Common failures
- Every scan reports authentication failure: verify plan activation, the service-scoped token, cached configuration, and whether token regeneration revoked the deployed value.
- Scans remain pending: confirm the queue worker is running, using the same queue connection, and can read current configuration.
- Rate-limited scans: stop manual re-submission, review plan capacity, and schedule scans rather than dispatching many clients simultaneously.
- Invalid-response failures: retain the safe failure code, compare the current official documentation with the boundary mapper, and update tests before changing production parsing.
- Duplicate tasks: ensure only pending scans execute and create tasks inside the same transaction as the completed result.
Final verification checklist
- The service plan is active and the token exists only in environment-backed configuration.
- The minimal request succeeds against the exact POST endpoint.
- An authorized user can queue a scan for a public HTTPS URL.
- The web request returns immediately while a worker performs analysis.
- Completed scans preserve score, severity-grouped findings, TLS details, and recommendations.
- Recommendations become practical remediation tasks.
- Authentication, validation, quota, connection, and upstream failures produce distinct safe states.
- Tests use
Http::fake()and never contact the live service. - The dashboard escapes external content and states that the result is not a penetration test.
- Token rotation includes configuration refresh and worker restart.
The important outcome is not another colorful score. It is a trustworthy loop: analyze, explain, assign, repair, and compare. When clients can see both history and the next concrete action, website security stops being an occasional report and becomes manageable work.