Tutorials

Laravel Client Dashboards: Integrate Website Security Analyzer for Actionable Client Insights

Laravel Client Dashboards: Integrate Website Security Analyzer for Actionable Client Insights

A security report becomes useful only when someone can see what changed, understand what matters, and turn recommendations into completed work. For a small web agency, that means more than placing an API response in a pretty card. The dashboard needs durable history, background execution, explicit failure states, and remediation tasks that survive the next scan.

This tutorial builds that workflow in Laravel and PHP 8.3+. Each client has an HTTPS website, a chronological scan history, severity-grouped findings, TLS details, and actionable recommendations. The Website Security Analyzer performs bounded, non-invasive analysis of public HTTPS and browser security posture. It should support routine hygiene and prioritization, but it must never be described as a penetration test.

Get access before writing integration code

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Website Security Analyzer service page. Choose an available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation. Find the Service token panel and copy the service-scoped token.
  4. Store that token in the application environment. Regenerating it revokes the previously active token, so coordinate rotation with deployment rather than regenerating it casually.

The API accepts a Bearer token, an X-API-Token header, or a token query parameter. This project uses a Bearer token because it keeps authentication out of URLs, access logs, and browser history.

The exact request is POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website, with a JSON body containing url. Verify access with a minimal request:

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 "Content-Type: application/json" \
  --data '{"url":"https://client.example"}'

Now put the credential in .env, never in PHP source, fixtures, screenshots, or logs:

WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Add environment-backed configuration to config/services.php:

'website_security_analyzer' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
    'token' => env('WEBSITE_SECURITY_ANALYZER_TOKEN'),
],

Choose an architecture that preserves history

The request should not wait for an external analysis to finish. A controller creates a pending scan, then dispatches a queue job. The job calls a dedicated API client, validates the response at the application boundary, stores an immutable snapshot, and creates open remediation tasks from recommendations.

This design costs a few database tables and a queue worker, but it provides faster browser responses, controlled retries, and an audit trail. It also keeps transport concerns out of controllers and views.

Prerequisites are PHP 8.3+, Composer, a supported Laravel database, and a queue backend. Start with a normal Laravel application and generate the main classes:

composer create-project laravel/laravel agency-security-dashboard
cd agency-security-dashboard
php artisan make:model Client -m
php artisan make:model SecurityScan -m
php artisan make:model RemediationTask -m
php artisan make:controller ClientSecurityController
php artisan make:job AnalyzeClientWebsite
php artisan make:policy ClientPolicy --model=Client
php artisan make:test WebsiteSecurityAnalyzerTest --unit

The relevant structure is deliberately small:

app/
  Data/WebsiteAnalysis.php
  Exceptions/AnalyzerFailure.php
  Http/Controllers/ClientSecurityController.php
  Jobs/AnalyzeClientWebsite.php
  Models/Client.php
  Models/SecurityScan.php
  Models/RemediationTask.php
  Services/WebsiteSecurityAnalyzer.php
resources/views/clients/security.blade.php
routes/web.php
tests/Unit/WebsiteSecurityAnalyzerTest.php

Persist snapshots and tasks separately

A scan is evidence captured at a point in time. A remediation task is mutable work. Keeping them separate lets an agency close a task without rewriting the historical response that created it.

Schema::create('clients', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('website_url', 2048);
    $table->timestamps();
});

Schema::create('security_scans', function (Blueprint $table) {
    $table->id();
    $table->foreignId('client_id')->constrained()->cascadeOnDelete();
    $table->string('status')->index(); // pending, running, completed, failed
    $table->double('score')->nullable();
    $table->json('findings')->nullable();
    $table->json('tls_details')->nullable();
    $table->json('recommendations')->nullable();
    $table->string('error_code')->nullable();
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});

Schema::create('remediation_tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('client_id')->constrained()->cascadeOnDelete();
    $table->foreignId('security_scan_id')->constrained()->cascadeOnDelete();
    $table->text('description');
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});

Configure the models with the corresponding hasMany and belongsTo relationships. Cast JSON columns to array and timestamps to datetime. Either declare the assigned fields in $fillable or use explicit property assignment throughout.

Validate the API response at one boundary

The supplied contract exposes a score, severity-grouped findings, TLS details, and recommendations. The adapter should not let an unexpected HTML error page or changed JSON shape leak into the domain.

<?php

namespace App\Data;

use App\Exceptions\AnalyzerFailure;

final readonly class WebsiteAnalysis
{
    public function __construct(
        public int|float $score,
        public array $findings,
        public array $tlsDetails,
        public array $recommendations,
    ) {}

    public static function fromPayload(mixed $payload): self
    {
        if (! is_array($payload)
            || ! is_int($payload['score'] ?? null) && ! is_float($payload['score'] ?? null)
            || ! is_array($payload['findings'] ?? null)
            || ! is_array($payload['tls'] ?? null)
            || ! is_array($payload['recommendations'] ?? null)
        ) {
            throw new AnalyzerFailure('schema', 'Unexpected analyzer response.');
        }

        foreach ($payload['findings'] as $group => $items) {
            if (! is_string($group) || ! is_array($items)) {
                throw new AnalyzerFailure('schema', 'Invalid findings grouping.');
            }
        }

        $recommendations = array_values(array_filter(
            $payload['recommendations'],
            static fn (mixed $value): bool => is_string($value) && trim($value) !== ''
        ));

        return new self(
            $payload['score'],
            $payload['findings'],
            $payload['tls'],
            $recommendations,
        );
    }
}

If the official documentation returns these fields inside a documented envelope, unwrap that envelope here and nowhere else. Do not scatter speculative fallbacks across jobs and views.

Use bounded timeouts and classified failures

<?php

namespace App\Services;

use App\Data\WebsiteAnalysis;
use App\Exceptions\AnalyzerFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

final class WebsiteSecurityAnalyzer
{
    public function analyze(string $url): WebsiteAnalysis
    {
        $token = config('services.website_security_analyzer.token');

        if (! is_string($token) || $token === '') {
            throw new AnalyzerFailure('configuration', 'Analyzer token is missing.');
        }

        try {
            $response = Http::acceptJson()
                ->asJson()
                ->withToken($token)
                ->connectTimeout(5)
                ->timeout(30)
                ->post(
                    config('services.website_security_analyzer.endpoint'),
                    ['url' => $url],
                );
        } catch (ConnectionException $exception) {
            throw new AnalyzerFailure('temporary', 'Analyzer connection failed.', previous: $exception);
        }

        if ($response->successful()) {
            return WebsiteAnalysis::fromPayload($response->json());
        }

        $status = $response->status();

        if (in_array($status, [401, 403], true)) {
            throw new AnalyzerFailure('authentication', 'Analyzer authentication failed.');
        }

        if ($status === 429) {
            $header = $response->header('Retry-After');
            $delay = ctype_digit((string) $header)
                ? max(30, min(900, (int) $header))
                : 120;

            throw new AnalyzerFailure('rate_limit', 'Analyzer rate limit reached.', $delay);
        }

        if ($status === 422) {
            throw new AnalyzerFailure('validation', 'Analyzer rejected the website URL.');
        }

        if ($status >= 500) {
            throw new AnalyzerFailure('temporary', 'Analyzer is temporarily unavailable.');
        }

        throw new AnalyzerFailure('upstream', "Unexpected analyzer status: {$status}.");
    }
}

AnalyzerFailure is a small RuntimeException subclass exposing public readonly kind and nullable retryAfter properties. Its constructor should accept PHP’s named previous argument as shown.

Run scans in the queue

Only connection failures, server failures, and rate limits deserve another attempt. Validation and authentication failures require human action, so retrying them only burns quota and hides the real problem.

final class AnalyzeClientWebsite implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 4;

    public function __construct(public SecurityScan $scan) {}

    public function backoff(): array
    {
        return [30, 120, 300];
    }

    public function handle(WebsiteSecurityAnalyzer $analyzer): void
    {
        $this->scan->update(['status' => 'running']);

        try {
            $result = $analyzer->analyze($this->scan->client->website_url);
        } catch (AnalyzerFailure $failure) {
            Log::warning('Website analysis failed', [
                'scan_id' => $this->scan->id,
                'client_id' => $this->scan->client_id,
                'kind' => $failure->kind,
                'attempt' => $this->attempts(),
            ]);

            if ($failure->kind === 'rate_limit' && $this->attempts() < $this->tries) {
                $this->release($failure->retryAfter ?? 120);
                return;
            }

            if ($failure->kind === 'temporary') {
                throw $failure;
            }

            $this->scan->update([
                'status' => 'failed',
                'error_code' => $failure->kind,
                'completed_at' => now(),
            ]);

            return;
        }

        DB::transaction(function () use ($result): void {
            $this->scan->update([
                'status' => 'completed',
                'score' => $result->score,
                'findings' => $result->findings,
                'tls_details' => $result->tlsDetails,
                'recommendations' => $result->recommendations,
                'completed_at' => now(),
            ]);

            foreach ($result->recommendations as $recommendation) {
                $this->scan->remediationTasks()->create([
                    'client_id' => $this->scan->client_id,
                    'description' => $recommendation,
                ]);
            }
        });
    }

    public function failed(?Throwable $failure): void
    {
        $this->scan->update([
            'status' => 'failed',
            'error_code' => 'retry_exhausted',
            'completed_at' => now(),
        ]);
    }
}

A controller action should authorize access, lock the client row in a transaction, reject a second pending or running scan, create the pending record, and dispatch the job after commit:

public function store(Client $client): RedirectResponse
{
    Gate::authorize('update', $client);

    DB::transaction(function () use ($client): void {
        $locked = Client::query()->lockForUpdate()->findOrFail($client->id);

        abort_if(
            $locked->securityScans()
                ->whereIn('status', ['pending', 'running'])
                ->exists(),
            409,
            'A scan is already in progress.'
        );

        $scan = $locked->securityScans()->create(['status' => 'pending']);
        AnalyzeClientWebsite::dispatch($scan)->afterCommit();
    });

    return back()->with('status', 'Security analysis queued.');
}

Define routes for the dashboard, scan creation, and task completion. The policy must ensure that the authenticated user owns the client; route-model binding alone is not authorization.

Route::middleware('auth')->group(function (): void {
    Route::get('/clients/{client}/security', [ClientSecurityController::class, 'show'])
        ->name('clients.security.show');

    Route::post('/clients/{client}/security/scans', [ClientSecurityController::class, 'store'])
        ->name('clients.security.scans.store');

    Route::patch('/clients/{client}/tasks/{task}', [ClientSecurityController::class, 'complete'])
        ->scopeBindings()
        ->name('clients.security.tasks.complete');
});

The Blade view can remain simple: show the latest score and completion time, iterate over findings by severity group, display TLS details as labeled values, list open remediation tasks with CSRF-protected completion forms, and place earlier scans below the current snapshot. Escape ordinary values with Blade’s {{ }}; do not render API text through {!! !!}.

Test the boundary without calling production

Laravel’s HTTP fake makes transport tests deterministic and proves that secrets and request bodies are assembled correctly.

public function test_it_maps_a_successful_analysis(): void
{
    config()->set('services.website_security_analyzer.token', 'test-token');
    config()->set(
        'services.website_security_analyzer.endpoint',
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
    );

    Http::fake([
        'https://ai.mihajlo.mk/*' => Http::response([
            'score' => 82,
            'findings' => ['high' => [], 'medium' => []],
            'tls' => [],
            'recommendations' => ['Review the site security configuration.'],
        ], 200),
    ]);

    $result = app(WebsiteSecurityAnalyzer::class)
        ->analyze('https://client.example');

    $this->assertSame(82, $result->score);
    $this->assertCount(1, $result->recommendations);

    Http::assertSent(fn ($request) =>
        $request->method() === 'POST'
        && $request['url'] === 'https://client.example'
        && $request->hasHeader('Authorization', 'Bearer test-token')
    );
}

public function test_authentication_failure_is_not_retried(): void
{
    config()->set('services.website_security_analyzer.token', 'invalid');
    Http::fake(['https://ai.mihajlo.mk/*' => Http::response([], 401)]);

    try {
        app(WebsiteSecurityAnalyzer::class)->analyze('https://client.example');
        $this->fail('Expected an authentication failure.');
    } catch (AnalyzerFailure $failure) {
        $this->assertSame('authentication', $failure->kind);
    }

    Http::assertSentCount(1);
}

Add job tests with Queue::fake() for dispatch and Http::fake() for completion, rate limiting, malformed JSON, and exhausted temporary failures. Assert database state, not internal method calls.

Production safeguards and deployment

  • Accept only normalized public https:// client URLs. Reject credentials in URLs, localhost names, and private or reserved IP destinations.
  • Encrypt environment secrets through the deployment platform and restrict who can view them. During rotation, deploy the regenerated token everywhere that consumes the queue before old workers continue processing.
  • Never log the token, authorization header, complete response, or client URL. Stable scan IDs, client IDs, failure categories, attempts, duration, and HTTP status categories provide useful observability.
  • Alert on repeated authentication failures, schema failures, retry exhaustion, and a growing queue. A schema failure is an integration alarm, not an empty successful report.
  • Label the dashboard clearly as a bounded, non-invasive website security analysis. Avoid language implying exploit validation, internal-network coverage, or penetration testing.

Deploy database migrations, rebuild configuration, and restart workers so they load the new token and code:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=4 --timeout=90

Run the worker under a process supervisor in production. Its process timeout must remain comfortably longer than the HTTP response timeout. Common failures are usually direct: a 401 or 403 indicates token configuration or rotation trouble; a 422 indicates an unacceptable URL; a 429 requires delayed retry or plan review; repeated 5xx or connection failures indicate a temporary upstream problem; and a schema failure means the adapter must be compared with the current official documentation.

Final verification checklist

  • A client can queue one scan without holding open the browser request.
  • Concurrent clicks cannot create multiple active scans for the same client.
  • Successful results retain score, grouped findings, TLS details, recommendations, and completion time.
  • Recommendations become closable tasks while the original scan remains immutable.
  • Authorization prevents one agency user from viewing or updating another user’s clients.
  • Authentication and validation failures stop immediately; transient failures use bounded backoff.
  • Tests make no network calls and contain no real service token.
  • The dashboard describes the result accurately and never calls it a penetration test.

The durable value is not the scan button. It is the loop around it: capture a bounded assessment, preserve what was observed, convert recommendations into owned work, and return later to measure what changed. That turns a security API from a one-off report into a calm, repeatable client service.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.