Laravel: AI Detects Client Site Tech to Auto-Generate Redesign Quotes
A redesign quote becomes risky when the visible pages tell only half the story. A polished brochure site might hide an aging CMS, several analytics products, an unknown JavaScript framework, and a redirect chain that complicates migration. Guessing turns those details into unpaid work.
This tutorial builds a Laravel application that submits a client’s public website to the Website Technology Detector API, converts the evidence-backed response into domain objects, and produces a reviewable redesign estimate. The estimate is intentionally a draft: technology detection can improve discovery, but it should not replace scope confirmation or commercial judgment.
Get access to the detector
Create an account at the registration page, or use the sign-in page if you already have one.
- Open the Website Technology Detector service page.
- Choose an 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 accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because credentials in query strings can leak into access logs, browser history, and monitoring systems.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment: update the application secret, deploy or reload the affected processes, verify a request, and only then consider the operation complete.
Confirm the exact API call
The integration sends POST requests to https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON body contains one required value, url. Test the credential without involving Laravel:
curl --fail-with-body \
--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 technology detections with confidence, evidence, and version information, plus redirect information where available. Those values come from deterministic inspection of the public website. Do not turn a confidence score into certainty: preserve the supplied evidence so a human can review why a technology was reported.
Prepare the Laravel project
You need PHP 8.3 or newer, Composer, a supported Laravel application, and a queue only if you later move bulk scans into background processing. The interactive quote workflow below remains synchronous and uses strict timeouts.
composer create-project laravel/laravel redesign-quoter
cd redesign-quoter
php artisan make:controller RedesignQuoteController
php artisan make:request PrepareRedesignQuoteRequest
mkdir -p app/Domain/Technology app/Services
php artisan make:test TechnologyDetectorTest
Store the credential in the project’s environment configuration. Never commit the real value:
# .env
TECHNOLOGY_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
TECHNOLOGY_DETECTOR_BASE_URL=https://ai.mihajlo.mk/api/website-technology-detector
Add the following entry inside the array returned by config/services.php:
'technology_detector' => [
'base_url' => env(
'TECHNOLOGY_DETECTOR_BASE_URL',
'https://ai.mihajlo.mk/api/website-technology-detector'
),
'token' => env('TECHNOLOGY_DETECTOR_TOKEN'),
],
The application now has four small layers: request validation protects the endpoint, the API client owns transport behavior, a mapper isolates uncertain external JSON, and the quote builder applies your commercial policy. That separation makes upstream changes and pricing changes independent.
Map the response at the boundary
External JSON should not spread through controllers and templates. The mapper below accepts only usable detections, preserves numeric confidence without assuming a particular scale, normalizes version and evidence values, and retains redirect-related top-level data without relying on undocumented redirect subfields.
<?php
// app/Domain/Technology/DetectionReport.php
namespace App\Domain\Technology;
use UnexpectedValueException;
final readonly class TechnologyDetection
{
public function __construct(
public string $name,
public ?float $confidence,
public array $versions,
public array $evidence,
) {}
}
final readonly class DetectionReport
{
public function __construct(
public array $technologies,
public array $redirectInformation,
) {}
public static function fromPayload(array $payload): self
{
$rows = $payload['technologies'] ?? null;
if (! is_array($rows)) {
throw new UnexpectedValueException(
'The detector response has no technologies array.'
);
}
$technologies = [];
foreach ($rows as $row) {
if (! is_array($row) || ! is_string($row['name'] ?? null)) {
continue;
}
$confidence = is_numeric($row['confidence'] ?? null)
? (float) $row['confidence']
: null;
$rawVersions = $row['versions'] ?? ($row['version'] ?? []);
$versions = is_array($rawVersions)
? $rawVersions
: [$rawVersions];
$evidence = $row['evidence'] ?? [];
$technologies[] = new TechnologyDetection(
trim($row['name']),
$confidence,
array_values(array_filter(
$versions,
fn ($value) => is_string($value) && $value !== ''
)),
is_array($evidence) ? $evidence : [],
);
}
$redirectInformation = array_filter(
$payload,
fn ($value, $key) =>
str_contains(strtolower((string) $key), 'redirect')
|| $key === 'final_url',
ARRAY_FILTER_USE_BOTH
);
return new self($technologies, $redirectInformation);
}
}
Supporting both a scalar version and an array of versions is boundary hardening, not a claim that both will always appear. The adapter deliberately discards malformed technology rows instead of letting one bad item corrupt the whole quote.
Build a bounded, retry-aware API client
Laravel’s built-in HTTP client supplies JSON encoding, authentication, timeouts, retries, and test fakes. Retries are limited to connection failures, HTTP 429 responses, and server errors. Authentication and validation failures are not retried because another identical request will not repair them.
<?php
// app/Services/TechnologyDetector.php
namespace App\Services;
use App\Domain\Technology\DetectionReport;
use Exception;
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 RuntimeException;
use Throwable;
final class TechnologyServiceException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
?Throwable $previous = null,
) {
parent::__construct('Technology detection failed.', 0, $previous);
}
}
final class TechnologyDetector
{
public function detect(string $url): DetectionReport
{
$token = config('services.technology_detector.token');
if (! is_string($token) || $token === '') {
throw new TechnologyServiceException('configuration');
}
try {
$response = Http::baseUrl(
rtrim(config('services.technology_detector.base_url'), '/')
)
->withToken($token)
->acceptJson()
->asJson()
->connectTimeout(3)
->timeout(15)
->retry(
3,
function (int $attempt, Exception $exception): int {
if ($exception instanceof RequestException
&& $exception->response->status() === 429) {
$seconds = (int) $exception->response
->header('Retry-After', '0');
return min(5000, max(250, $seconds * 1000));
}
return min(2000, 250 * (2 ** ($attempt - 1)));
},
function (Exception $exception, PendingRequest $request): bool {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException
&& (
$exception->response->status() === 429
|| $exception->response->serverError()
));
},
throw: false,
)
->post('/v1/detect-technologies', ['url' => $url]);
} catch (ConnectionException $exception) {
throw new TechnologyServiceException(
'network',
previous: $exception
);
}
if ($response->status() === 401 || $response->status() === 403) {
throw new TechnologyServiceException(
'authentication',
$response->status()
);
}
if ($response->status() === 429) {
throw new TechnologyServiceException(
'rate_limit',
429,
(int) $response->header('Retry-After', '0')
);
}
if ($response->status() === 422) {
throw new TechnologyServiceException('invalid_request', 422);
}
if ($response->serverError()) {
throw new TechnologyServiceException('upstream', $response->status());
}
if (! $response->successful() || ! is_array($response->json())) {
throw new TechnologyServiceException(
'invalid_response',
$response->status()
);
}
try {
return DetectionReport::fromPayload($response->json());
} catch (Throwable $exception) {
Log::warning('Technology detector response could not be mapped', [
'status' => $response->status(),
'host' => parse_url($url, PHP_URL_HOST),
]);
throw new TechnologyServiceException(
'invalid_response',
$response->status(),
previous: $exception
);
}
}
}
The token and response body never enter logs. The only target detail logged is the hostname, which is usually enough to correlate an incident without retaining paths or query strings that may contain client data.
Turn detections into a quote draft
Detection cannot reveal content volume, stakeholder delays, custom business logic, accessibility requirements, or data-migration quality. Therefore, use it to establish a technical discovery range, not an unconditional fixed price.
This example applies a deliberately transparent business rule: twelve base discovery hours, two hours per detected technology, and one additional hour for detections that include version information, capped at forty hours. Replace these figures with your own reviewed estimating policy.
<?php
// app/Domain/Technology/RedesignQuoteBuilder.php
namespace App\Domain\Technology;
final class RedesignQuoteBuilder
{
public function build(DetectionReport $report): array
{
$versioned = count(array_filter(
$report->technologies,
fn (TechnologyDetection $item) => $item->versions !== []
));
$discoveryHours = min(
40,
12 + (count($report->technologies) * 2) + $versioned
);
return [
'status' => 'draft_requires_review',
'discovery_hours' => $discoveryHours,
'detected_technology_count' => count($report->technologies),
'versioned_detection_count' => $versioned,
'has_redirect_information' =>
$report->redirectInformation !== [],
'assumptions' => [
'Publicly observable technology only',
'Content and data migration require separate review',
'Confidence and evidence require human verification',
],
];
}
}
Validate incoming URLs and protect the route. The form request prevents non-HTTP schemes and embedded credentials. Authentication and throttling stop the endpoint from becoming a public proxy for consuming your plan quota.
<?php
// app/Http/Requests/PrepareRedesignQuoteRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
final class PrepareRedesignQuoteRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return ['url' => ['required', 'url:http,https', 'max:2048']];
}
protected function passedValidation(): void
{
if (parse_url($this->string('url')->toString(), PHP_URL_USER) !== null) {
abort(422, 'URLs containing credentials are not accepted.');
}
}
}
// app/Http/Controllers/RedesignQuoteController.php
namespace App\Http\Controllers;
use App\Domain\Technology\RedesignQuoteBuilder;
use App\Http\Requests\PrepareRedesignQuoteRequest;
use App\Services\TechnologyDetector;
use App\Services\TechnologyServiceException;
use Illuminate\Http\JsonResponse;
final class RedesignQuoteController extends Controller
{
public function __invoke(
PrepareRedesignQuoteRequest $request,
TechnologyDetector $detector,
RedesignQuoteBuilder $builder,
): JsonResponse {
try {
$report = $detector->detect($request->validated('url'));
} catch (TechnologyServiceException $exception) {
$status = $exception->kind === 'rate_limit' ? 429 : 503;
return response()->json([
'error' => $exception->kind,
'retry_after' => $exception->retryAfter,
], $status);
}
return response()->json([
'quote' => $builder->build($report),
'detections' => $report->technologies,
'redirect_information' => $report->redirectInformation,
]);
}
}
// routes/web.php
use App\Http\Controllers\RedesignQuoteController;
use Illuminate\Support\Facades\Route;
Route::post('/redesign-quotes/prepare', RedesignQuoteController::class)
->middleware(['auth', 'throttle:10,1']);
Test success and failure paths
Http::fake() keeps tests deterministic and proves that the application sends the exact endpoint, JSON field, and authentication header. It also lets us verify structured handling of exhausted rate limits without consuming real quota.
<?php
// tests/Feature/TechnologyDetectorTest.php
namespace Tests\Feature;
use App\Services\TechnologyDetector;
use App\Services\TechnologyServiceException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class TechnologyDetectorTest extends TestCase
{
public function test_it_maps_detections_and_sends_bearer_token(): void
{
config()->set('services.technology_detector.token', 'test-token');
config()->set(
'services.technology_detector.base_url',
'https://ai.mihajlo.mk/api/website-technology-detector'
);
Http::fake([
'*/v1/detect-technologies' => Http::response([
'technologies' => [[
'name' => 'Example CMS',
'confidence' => 90,
'version' => '4.2',
'evidence' => ['generator metadata'],
]],
'redirects' => [],
'final_url' => 'https://example.com/',
]),
]);
$report = app(TechnologyDetector::class)
->detect('https://example.com');
$this->assertSame('Example CMS', $report->technologies[0]->name);
$this->assertSame(['4.2'], $report->technologies[0]->versions);
Http::assertSent(fn (Request $request) =>
$request->url()
=== 'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
&& $request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://example.com'
);
}
public function test_it_exposes_exhausted_rate_limit_as_domain_failure(): void
{
config()->set('services.technology_detector.token', 'test-token');
Http::fake([
'*' => Http::response(
['message' => 'Too many requests'],
429,
['Retry-After' => '1']
),
]);
try {
app(TechnologyDetector::class)->detect('https://example.com');
$this->fail('Expected a service exception.');
} catch (TechnologyServiceException $exception) {
$this->assertSame('rate_limit', $exception->kind);
$this->assertSame(429, $exception->status);
$this->assertSame(1, $exception->retryAfter);
}
}
}
Also add tests for a missing token, malformed JSON, absent technology arrays, connection failures, 401 responses, and malformed detection rows. Run the suite with php artisan test.
Security, observability, and deployment
Do not expose this route anonymously. Beyond authentication and throttling, consider an account-level quota when several users share one service token. If quoting is limited to known prospects, an approved-domain list or website-ownership check provides stronger abuse protection.
Never log the token, authorization headers, or complete upstream body. Record request duration, final outcome category, HTTP status, retry count, and a correlation identifier. Alert on sustained authentication failures, rate limiting, malformed responses, and elevated latency. A single transient server error is operational noise; a repeated mapping failure may indicate a response-contract change.
During deployment, provide the token through the platform’s secret store and run:
php artisan optimize
php artisan test
Restart long-running PHP or worker processes after secret rotation so they reload cached configuration. Do not run config:cache during image creation unless the deployment environment already supplies the correct variables.
Synchronous detection suits an interactive single-site quote because failure feedback is immediate and the request has a fifteen-second ceiling. For imported lead lists or scheduled rescans, move the same service call into a Laravel queue job, make the job idempotent, store its state, and delay rate-limited jobs according to Retry-After. Do not keep an HTTP request open for batch work.
Common failures to recognize
- 401 or 403: the token is missing, invalid, revoked, or not valid for the service. Do not retry automatically.
- 422: the submitted URL is unacceptable. Show a validation message and require correction.
- 429: the active plan or request rate has been exceeded. Respect
Retry-Afterand avoid retry storms. - 5xx or connection failure: retry briefly with bounded backoff, then return a recoverable service-unavailable state.
- Malformed success response: reject it at the mapper, log metadata rather than the body, and investigate the contract.
- Unexpectedly sparse results: treat the scan as incomplete discovery, not proof that the site has a simple architecture.
Final verification checklist
- The request uses
POSTand the exact/v1/detect-technologiespath. - The JSON body contains
url, and the Bearer token comes from environment-backed configuration. - Connection and total response timeouts are bounded.
- Only connection failures, 429 responses, and server failures are retried.
- Detections, confidence, evidence, versions, and redirect information are mapped defensively.
- The quote remains explicitly marked for human review.
- Authentication, throttling, structured logs, tests, and token-rotation procedures are in place.
The valuable automation is not a mysterious number printed beside a URL. It is a disciplined handoff: observable technology becomes preserved evidence, evidence becomes a transparent estimate, and the estimate becomes a better client conversation. That is how a detector helps produce faster quotes without turning uncertainty into false confidence.