Laravel: Automate Client Website Audits for Redesign Quotes with Tech Detector API
A redesign quote becomes risky when it is based only on what a browser reveals. A polished homepage may conceal an aging CMS, several analytics products, a JavaScript-heavy storefront, or infrastructure that will complicate migration. Detecting that stack before estimating the work gives a freelancer or small development team a better foundation for scope, questions, and pricing.
This tutorial builds a production-oriented Laravel application that submits a public client URL to the Website Technology Detector API, converts the response into domain objects, and returns evidence-backed technology findings for a redesign assessment. The integration uses Laravel’s built-in HTTP client, bounded timeouts, selective retries, structured errors, safe logging, and deterministic tests.
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 Technology Detector 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 the service-scoped token.
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 credentials in query strings are more likely to appear in access logs, browser history, and monitoring systems.
Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed environment promptly, clear cached Laravel configuration, and verify the integration before removing any operational alert.
Confirm the exact endpoint
The required request is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON body contains one url. Test the credential directly before writing application code:
curl --request POST \
--url https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://example.com"}'
A successful response contains confidence-scored technology detections with evidence, version information when available, and redirect information. Confidence values and evidence should inform an estimate, not be treated as infallible proof. A site can hide server-side components, remove identifying headers, or load technologies only after particular user interactions.
Create the Laravel project boundary
The implementation remains deliberately small: a controller validates the public URL, a service owns the remote HTTP exchange, and domain objects normalize the response. The request stays synchronous because a user auditing one website benefits from an immediate result. If the product later accepts lists of sites, move the same service call into queued jobs rather than keeping web requests open.
Start with a current Laravel application running PHP 8.3 or newer:
composer create-project laravel/laravel redesign-auditor
cd redesign-auditor
php artisan make:controller WebsiteAuditController
php artisan make:test TechnologyDetectorTest
The relevant project structure will be:
app/
Domain/WebsiteAudit/TechnologyAudit.php
Exceptions/TechnologyDetectorFailure.php
Http/Controllers/WebsiteAuditController.php
Services/TechnologyDetector.php
config/
services.php
routes/
api.php
tests/
Feature/TechnologyDetectorTest.php
Put the credential in environment-backed configuration
Add placeholders to .env.example, then put the real token only in the uncommitted .env:
'technology_detector' => [
'url' => env(
'TECHNOLOGY_DETECTOR_URL',
'https://ai.mihajlo.mk/api/website-technology-detector'
),
'token' => env('TECHNOLOGY_DETECTOR_TOKEN'),
],
Application code must read config(), not call env() directly. That distinction matters after php artisan config:cache compiles configuration for production.
Map the response at the application boundary
Remote JSON should not spread through controllers and quote-building code. The following domain mapper accepts either a direct payload or a conventional data envelope, ignores malformed list entries, and gives optional fields safe defaults. It does not rescale confidence because the service’s returned scale should be preserved exactly.
<?php
namespace App\Domain\WebsiteAudit;
final readonly class TechnologyDetection
{
public function __construct(
public string $name,
public ?float $confidence,
public array $versions,
public array $evidence,
) {}
public static function fromApi(array $item): ?self
{
$name = $item['name'] ?? null;
if (! is_string($name) || trim($name) === '') {
return null;
}
$confidence = $item['confidence'] ?? null;
return new self(
name: trim($name),
confidence: is_numeric($confidence) ? (float) $confidence : null,
versions: self::stringList($item['versions'] ?? []),
evidence: is_array($item['evidence'] ?? null)
? $item['evidence']
: [],
);
}
private static function stringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_filter(
$value,
static fn (mixed $item): bool => is_string($item)
&& trim($item) !== ''
));
}
}
final readonly class TechnologyAudit
{
public function __construct(
public array $technologies,
public array $redirects,
) {}
public static function fromApi(array $payload): self
{
$body = is_array($payload['data'] ?? null)
? $payload['data']
: $payload;
$items = is_array($body['technologies'] ?? null)
? $body['technologies']
: [];
$technologies = array_values(array_filter(array_map(
static fn (mixed $item): ?TechnologyDetection =>
is_array($item)
? TechnologyDetection::fromApi($item)
: null,
$items,
)));
return new self(
technologies: $technologies,
redirects: is_array($body['redirects'] ?? null)
? array_values($body['redirects'])
: [],
);
}
}
This mapper validates the documented semantic fields instead of trusting their PHP types. Missing versions are not errors, and evidence remains structured because flattening it would discard the reason behind a detection. If the official documentation changes its JSON envelope, this is the single class that should change.
Build a resilient detector service
Define a structured exception so callers can distinguish configuration, authentication, quota, validation, network, and upstream failures:
<?php
namespace App\Exceptions;
use RuntimeException;
final class TechnologyDetectorFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $upstreamStatus = null,
public readonly bool $retryable = false,
) {
parent::__construct($message);
}
}
Now create app/Services/TechnologyDetector.php. The service sets separate connection and total-response limits. It retries connection failures, rate limits, and transient server responses with short bounded backoff. Authentication and validation failures are deliberately excluded from retries because repeating an unchanged request cannot repair them.
<?php
namespace App\Services;
use App\Domain\WebsiteAudit\TechnologyAudit;
use App\Exceptions\TechnologyDetectorFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class TechnologyDetector
{
public function detect(string $url): TechnologyAudit
{
$baseUrl = rtrim((string) config('services.technology_detector.url'), '/');
$token = (string) config('services.technology_detector.token');
if ($token === '') {
throw new TechnologyDetectorFailure(
'configuration',
'The technology detector token is not configured.'
);
}
$started = hrtime(true);
$host = parse_url($url, PHP_URL_HOST) ?: 'unknown';
try {
$response = Http::baseUrl($baseUrl)
->acceptJson()
->asJson()
->withToken($token)
->connectTimeout(3)
->timeout(15)
->retry(
[200, 500],
0,
static function (
Throwable $exception,
PendingRequest $request
): bool {
if ($exception instanceof ConnectionException) {
return true;
}
return $exception instanceof RequestException
&& in_array(
$exception->response->status(),
[429, 500, 502, 503, 504],
true
);
},
false
)
->post('/v1/detect-technologies', ['url' => $url]);
} catch (ConnectionException $exception) {
Log::warning('technology_detector.network_failure', [
'host' => $host,
'message' => $exception->getMessage(),
]);
throw new TechnologyDetectorFailure(
'network',
'The detector could not be reached.',
retryable: true
);
}
Log::info('technology_detector.completed', [
'host' => $host,
'status' => $response->status(),
'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
]);
if ($response->successful()) {
$payload = $response->json();
if (! is_array($payload)) {
throw new TechnologyDetectorFailure(
'invalid_response',
'The detector returned invalid JSON.',
$response->status(),
true
);
}
return TechnologyAudit::fromApi($payload);
}
$status = $response->status();
throw match (true) {
in_array($status, [401, 403], true) =>
new TechnologyDetectorFailure(
'authentication',
'The service token was rejected.',
$status
),
$status === 422 =>
new TechnologyDetectorFailure(
'validation',
'The detector rejected the submitted URL.',
$status
),
$status === 429 =>
new TechnologyDetectorFailure(
'rate_limit',
'The detector quota or rate limit was reached.',
$status,
true
),
$status >= 500 =>
new TechnologyDetectorFailure(
'upstream',
'The detector is temporarily unavailable.',
$status,
true
),
default =>
new TechnologyDetectorFailure(
'request',
'The detector request failed.',
$status
),
};
}
}
The log records the hostname, status, and duration, but not the token, response body, evidence, or full URL. Full URLs can contain client identifiers or query parameters, while response evidence may expose implementation details that do not belong in broadly accessible logs.
Expose the audit endpoint
Create app/Http/Controllers/WebsiteAuditController.php. Besides Laravel’s URL validation, the controller rejects localhost and private or reserved IP literals. That keeps the feature aligned with its purpose: inspecting public websites.
<?php
namespace App\Http\Controllers;
use App\Exceptions\TechnologyDetectorFailure;
use App\Services\TechnologyDetector;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
final class WebsiteAuditController extends Controller
{
public function __invoke(
Request $request,
TechnologyDetector $detector
): JsonResponse {
$validated = $request->validate([
'url' => ['required', 'string', 'url:http,https', 'max:2048'],
]);
$host = parse_url($validated['url'], PHP_URL_HOST);
if (
! 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
)
)
) {
throw ValidationException::withMessages([
'url' => 'Enter a publicly reachable website URL.',
]);
}
try {
$audit = $detector->detect($validated['url']);
} catch (TechnologyDetectorFailure $failure) {
$status = match ($failure->kind) {
'validation' => 422,
'rate_limit' => 429,
'configuration', 'authentication' => 503,
default => 502,
};
return response()->json([
'error' => [
'type' => $failure->kind,
'message' => $failure->getMessage(),
'retryable' => $failure->retryable,
],
], $status);
}
return response()->json([
'technologies' => array_map(
static fn ($technology): array => [
'name' => $technology->name,
'confidence' => $technology->confidence,
'versions' => $technology->versions,
'evidence' => $technology->evidence,
],
$audit->technologies
),
'redirects' => $audit->redirects,
]);
}
}
Register the endpoint in routes/api.php. The route-level limit protects your plan from accidental loops and casual abuse; authenticated applications can replace it with a per-user policy.
<?php
use App\Http\Controllers\WebsiteAuditController;
use Illuminate\Support\Facades\Route;
Route::post('/website-audits', WebsiteAuditController::class)
->middleware('throttle:10,1');
Test success and non-retryable failure
Laravel’s Http::fake() keeps tests deterministic and ensures no paid or quota-limited request escapes the suite. The success test proves request authentication and mapping; the authentication test proves that a rejected token is not retried.
<?php
namespace Tests\Feature;
use App\Exceptions\TechnologyDetectorFailure;
use App\Services\TechnologyDetector;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class TechnologyDetectorTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set(
'services.technology_detector.url',
'https://ai.mihajlo.mk/api/website-technology-detector'
);
config()->set(
'services.technology_detector.token',
'test-service-token'
);
}
public function test_it_maps_a_technology_audit(): void
{
Http::fake([
'*/v1/detect-technologies' => Http::response([
'data' => [
'technologies' => [[
'name' => 'Example CMS',
'confidence' => 92,
'versions' => ['6.x'],
'evidence' => ['generator metadata'],
]],
'redirects' => [
['from' => 'http://example.com',
'to' => 'https://example.com'],
],
],
], 200),
]);
$audit = app(TechnologyDetector::class)
->detect('https://example.com');
$this->assertCount(1, $audit->technologies);
$this->assertSame(
'Example CMS',
$audit->technologies[0]->name
);
$this->assertSame(
92.0,
$audit->technologies[0]->confidence
);
Http::assertSent(fn ($request): bool =>
$request->url()
=== 'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
&& $request->hasHeader(
'Authorization',
'Bearer test-service-token'
)
&& $request['url'] === 'https://example.com'
);
}
public function test_authentication_failure_is_not_retried(): void
{
Http::fake([
'*/v1/detect-technologies' =>
Http::response(['message' => 'Unauthorized'], 401),
]);
try {
app(TechnologyDetector::class)
->detect('https://example.com');
$this->fail('Expected detector failure was not thrown.');
} catch (TechnologyDetectorFailure $failure) {
$this->assertSame('authentication', $failure->kind);
$this->assertFalse($failure->retryable);
}
Http::assertSentCount(1);
}
}
Run the suite and exercise the application boundary:
php artisan test
php artisan serve
curl --request POST \
--url http://127.0.0.1:8000/api/website-audits \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://example.com"}'
Production security, observability, and deployment
Keep the endpoint behind your application’s authentication if audit results are part of a private quoting workflow. Rate limiting protects API allowance, but it is not authorization. Do not render returned evidence as trusted HTML; store it as structured data and escape it in any quote interface.
Monitor counts and latency for successful calls, network failures, 429 responses, and upstream 5xx responses. A sustained authentication failure usually means an expired, regenerated, or incorrectly deployed token. A rate-limit response should pause bulk processing rather than trigger aggressive immediate retries.
Deploy the application secret through the hosting platform, then rebuild Laravel’s cached configuration:
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan test --testsuite=Feature
When rotating the token, update TECHNOLOGY_DETECTOR_TOKEN, rerun config:cache, and perform one controlled audit. Remember that regeneration revokes the old active token, so an outdated instance will begin receiving authentication errors immediately.
Common failures and what they mean
- 401 or 403: the token is absent, revoked, copied incorrectly, or not available to the cached configuration. Do not retry automatically.
- 422: the submitted URL was rejected. Return a correction-oriented validation message rather than treating it as an outage.
- 429: the plan quota or rate limit has been reached. Surface a retryable state, respect service guidance, and defer batch work.
- Connection timeout: DNS, networking, or the upstream service may be unavailable. The bounded retry handles brief faults without tying up a worker indefinitely.
- Empty detections: this can be a valid result. It does not prove the website uses no technology; it means the public evidence did not produce mapped detections.
- Unexpected JSON: treat it as an upstream contract failure, retain safe status metadata, and update the boundary mapper against the official documentation.
Final verification checklist
- The account and Free, Plus, or Pro plan are active.
- The service-scoped token is stored outside source control.
- The application calls the exact
POSTdetector endpoint with a JSONurl. - Connection and total-response timeouts are bounded.
- Only network, rate-limit, and transient server failures are retried.
- Detections, confidence, versions, evidence, and redirects cross a defensive domain boundary.
- Logs omit tokens, response bodies, and full client URLs.
Http::fake()tests cover mapping and non-retryable authentication failure.- Cached production configuration contains the current token.
A useful redesign audit does not replace technical discovery; it makes discovery sharper. The detected stack tells you which migration questions to ask, the evidence shows why each technology was reported, and redirect information exposes routing behavior that a homepage screenshot cannot. With a narrow Laravel boundary and disciplined failure handling, that intelligence becomes dependable input to a quote instead of another fragile API call.