Tutorials

Laravel: Automate Website Redesign Quotes by Detecting Client Tech Stacks

Laravel: Automate Website Redesign Quotes by Detecting Client Tech Stacks

A redesign quote can go wrong before anyone discusses typography or page layouts. A site that looks simple may hide a hosted-commerce platform, several analytics products, a JavaScript-heavy frontend, legacy plugins, and redirects left behind by previous migrations. If discovery relies on a quick visual inspection, those details appear later as scope changes.

This tutorial builds a production-oriented Laravel preflight that accepts a client URL, calls the Website Technology Detector API, and returns an evidence-backed stack report for the person preparing the quote. It does not pretend that technology detection can calculate a final price. It automates the mechanical investigation so that pricing decisions begin with better information.

Get access and copy the service token

Complete access setup before writing integration code:

  1. Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
  2. Open the Website Technology Detector service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. 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. The implementation below uses a Bearer token because query parameters commonly appear in access logs and monitoring systems.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: replace the secret in every deployed environment, rebuild cached Laravel configuration, verify the integration, and only then consider the rollout complete.

Confirm the exact endpoint

The required call is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. It receives JSON containing url. Test it once from a secure terminal, using a placeholder here rather than committing a real credential:

curl --request POST \
  '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"}'

Inspect the documented response alongside this test. The integration must preserve confidence-scored detections, evidence, versions, and redirect information. Those values are useful during discovery, but they remain observations about a public website rather than guarantees about its source code or hosting account.

Store the credential in Laravel configuration

The example assumes PHP 8.3 or later, an existing Laravel application with authentication, and a test database that supports its user factory. No third-party HTTP package is necessary; Laravel’s built-in client is sufficient.

composer create-project laravel/laravel redesign-preflight
cd redesign-preflight

# .env
WEBSITE_TECH_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN

Add a dedicated entry to config/services.php. Keeping the endpoint in configuration also makes tests deterministic without scattering URLs through the application.

// config/services.php
'website_technology_detector' => [
    'endpoint' => env(
        'WEBSITE_TECH_DETECTOR_ENDPOINT',
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies',
    ),
    'token' => env('WEBSITE_TECH_DETECTOR_TOKEN'),
],

Do not call env() from application classes. Laravel’s configuration cache is designed around values being read through config().

Keep the API boundary small

The application has four relevant pieces:

  • WebsiteTechnologyDetector owns authentication, timeouts, retries, and HTTP status handling.
  • TechnologyReport maps untrusted JSON into a stable domain object.
  • QuotePreflightController validates the submitted target and exposes quote-relevant findings.
  • A protected, rate-limited route prevents anonymous callers from consuming the plan quota.

This synchronous design is appropriate for a person requesting one report while preparing a quote. A queue would introduce persistence, workers, job status, and duplicate-submission concerns without improving this workflow. If reports later become batch imports, the same detector service can be called from an idempotent queued job.

Map the response defensively

Remote JSON is not a trusted domain object. The mapper below accepts only arrays, ignores malformed detection rows, preserves structured evidence, and never assumes that confidence is a percentage. The response contract’s detection and redirect collections remain isolated here, so a documented schema revision has one adaptation point.

<?php
// app/Domain/Quotes/TechnologyReport.php

namespace App\Domain\Quotes;

use UnexpectedValueException;

final readonly class TechnologyReport
{
    public function __construct(
        public array $detections,
        public array $redirects,
    ) {}

    public static function fromApi(array $body): self
    {
        $payload = isset($body['data']) && is_array($body['data'])
            ? $body['data']
            : $body;

        $rows = $payload['technologies'] ?? $payload['detections'] ?? null;

        if (! is_array($rows)) {
            throw new UnexpectedValueException(
                'Detector response has no detection collection.'
            );
        }

        $detections = [];

        foreach ($rows as $row) {
            if (! is_array($row) || ! is_string($row['name'] ?? null)) {
                continue;
            }

            $confidence = is_numeric($row['confidence'] ?? null)
                ? (float) $row['confidence']
                : null;

            $detections[] = [
                'name' => $row['name'],
                'confidence' => $confidence,
                'versions' => is_array($row['versions'] ?? null)
                    ? array_values($row['versions'])
                    : [],
                'evidence' => is_array($row['evidence'] ?? null)
                    ? $row['evidence']
                    : [],
            ];
        }

        $redirects = is_array($payload['redirects'] ?? null)
            ? array_values($payload['redirects'])
            : [];

        return new self($detections, $redirects);
    }
}

Do not discard an empty detection list: a valid public page may expose little recognizable evidence. That is different from a malformed response, which raises a protocol failure.

Add bounded retries and structured failures

Connection failures, HTTP 429 responses, and selected temporary server errors deserve limited retries. Authentication and validation failures do not: repeating the same bad token or URL merely consumes time and creates noise.

<?php
// app/Services/WebsiteTechnologyDetector.php

namespace App\Services;

use App\Domain\Quotes\TechnologyReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

final class DetectorFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $upstreamStatus = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct("Technology detector failed: {$kind}", 0, $previous);
    }
}

final class WebsiteTechnologyDetector
{
    public function detect(string $url): TechnologyReport
    {
        $endpoint = config('services.website_technology_detector.endpoint');
        $token = config('services.website_technology_detector.token');

        if (! is_string($token) || $token === '') {
            throw new DetectorFailure('configuration');
        }

        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) {
                    throw new DetectorFailure(
                        'connection',
                        previous: $exception,
                    );
                }

                $this->pause($attempt, null);
                continue;
            }

            if ($response->successful()) {
                try {
                    $json = $response->json();

                    if (! is_array($json)) {
                        throw new RuntimeException('Response was not JSON.');
                    }

                    return TechnologyReport::fromApi($json);
                } catch (Throwable $exception) {
                    throw new DetectorFailure(
                        'protocol',
                        $response->status(),
                        $exception,
                    );
                }
            }

            $status = $response->status();

            if (in_array($status, [401, 403], true)) {
                throw new DetectorFailure('authentication', $status);
            }

            if (in_array($status, [400, 422], true)) {
                throw new DetectorFailure('rejected_url', $status);
            }

            $retryable = $status === 429
                || in_array($status, [500, 502, 503, 504], true);

            if (! $retryable || $attempt === 3) {
                $kind = $status === 429 ? 'rate_limited' : 'upstream';

                throw new DetectorFailure($kind, $status);
            }

            Log::notice('technology_detector_retry', [
                'attempt' => $attempt,
                'status' => $status,
            ]);

            $this->pause($attempt, $response->header('Retry-After'));
        }

        throw new DetectorFailure('upstream');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $seconds = ctype_digit((string) $retryAfter)
            ? min(5, max(1, (int) $retryAfter))
            : min(2, $attempt);

        usleep($seconds * 1_000_000);
    }
}

Both timeouts and the three-attempt ceiling are deliberate. The bounded Retry-After handling respects a numeric server hint without allowing one request to hold a PHP worker indefinitely. The service does not log tokens, response bodies, evidence, or complete client URLs.

Expose a protected quote preflight

The controller accepts only HTTP or HTTPS URLs. Because the Laravel application never fetches the target directly, it is not acting as a general-purpose proxy. Even so, rejecting credentials, localhost names, and non-public IP literals catches obvious mistakes and abuse. Authentication and route throttling add the more important quota boundary.

<?php
// app/Http/Controllers/QuotePreflightController.php

namespace App\Http\Controllers;

use App\Services\DetectorFailure;
use App\Services\WebsiteTechnologyDetector;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

final class QuotePreflightController extends Controller
{
    public function __invoke(
        Request $request,
        WebsiteTechnologyDetector $detector,
    ): JsonResponse {
        $validated = $request->validate([
            'url' => [
                'required',
                'string',
                'max:2048',
                function (string $attribute, mixed $value, Closure $fail): void {
                    $parts = is_string($value) ? parse_url($value) : false;
                    $scheme = is_array($parts) ? ($parts['scheme'] ?? null) : null;
                    $host = is_array($parts) ? ($parts['host'] ?? null) : null;

                    if (
                        ! in_array($scheme, ['http', 'https'], true)
                        || ! is_string($host)
                        || isset($parts['user'])
                        || $host === 'localhost'
                        || str_ends_with($host, '.local')
                    ) {
                        $fail('The URL must identify a public HTTP website.');
                        return;
                    }

                    if (
                        filter_var($host, FILTER_VALIDATE_IP)
                        && ! filter_var(
                            $host,
                            FILTER_VALIDATE_IP,
                            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
                        )
                    ) {
                        $fail('Private and reserved IP addresses are not allowed.');
                    }
                },
            ],
        ]);

        try {
            $report = $detector->detect($validated['url']);
        } catch (DetectorFailure $failure) {
            Log::warning('technology_detector_terminal_failure', [
                'kind' => $failure->kind,
                'upstream_status' => $failure->upstreamStatus,
                'target_host' => parse_url($validated['url'], PHP_URL_HOST),
            ]);

            return response()->json([
                'status' => 'temporarily_unavailable',
                'failure' => $failure->kind,
            ], 503);
        }

        return response()->json([
            'status' => 'ready',
            'preflight' => [
                'detected_count' => count($report->detections),
                'technologies' => $report->detections,
                'redirects' => $report->redirects,
                'manual_evidence_review' => array_values(array_map(
                    fn (array $item): string => $item['name'],
                    array_filter(
                        $report->detections,
                        fn (array $item): bool => $item['evidence'] === [],
                    ),
                )),
            ],
        ]);
    }
}

Register the endpoint in routes/web.php:

use App\Http\Controllers\QuotePreflightController;
use Illuminate\Support\Facades\Route;

Route::post('/quote-preflight', QuotePreflightController::class)
    ->middleware(['auth', 'throttle:10,1'])
    ->name('quote-preflight');

The response gives an estimator the detected technologies, their confidence values, versions, supporting evidence, redirect path, and an explicit list requiring manual evidence review. Pricing rules should live elsewhere and remain editable business policy. For example, a redirect migration or hosted checkout may affect scope, but neither should silently add money without a developer reviewing what the evidence actually means.

Test success and failure paths without network calls

Http::fake() prevents tests from consuming quota and makes retry assertions deterministic. The fixture below exercises the mapper’s supported contract shapes; keep production fixtures sanitized and free of client data or credentials.

<?php
// tests/Feature/QuotePreflightTest.php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class QuotePreflightTest extends TestCase
{
    use RefreshDatabase;

    private string $endpoint =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    protected function setUp(): void
    {
        parent::setUp();

        config([
            'services.website_technology_detector.endpoint' => $this->endpoint,
            'services.website_technology_detector.token' => 'test-token',
        ]);
    }

    public function test_authenticated_user_receives_stack_preflight(): void
    {
        Http::fake([
            $this->endpoint => Http::response([
                'data' => [
                    'technologies' => [[
                        'name' => 'Example CMS',
                        'confidence' => 0.94,
                        'versions' => ['1.2'],
                        'evidence' => ['generator' => 'Example CMS'],
                    ]],
                    'redirects' => [
                        ['from' => 'http://example.com', 'to' => 'https://example.com'],
                    ],
                ],
            ], 200),
        ]);

        $this->actingAs(User::factory()->create())
            ->postJson(route('quote-preflight'), [
                'url' => 'https://example.com',
            ])
            ->assertOk()
            ->assertJsonPath('status', 'ready')
            ->assertJsonPath('preflight.detected_count', 1)
            ->assertJsonPath(
                'preflight.technologies.0.name',
                'Example CMS',
            );

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

    public function test_authentication_failure_is_not_retried(): void
    {
        Http::fake([
            $this->endpoint => Http::response([], 401),
        ]);

        $this->actingAs(User::factory()->create())
            ->postJson(route('quote-preflight'), [
                'url' => 'https://example.com',
            ])
            ->assertStatus(503)
            ->assertJsonPath('failure', 'authentication');

        Http::assertSentCount(1);
    }

    public function test_private_ip_is_rejected_before_api_call(): void
    {
        Http::fake();

        $this->actingAs(User::factory()->create())
            ->postJson(route('quote-preflight'), [
                'url' => 'http://127.0.0.1/admin',
            ])
            ->assertUnprocessable()
            ->assertJsonValidationErrors('url');

        Http::assertNothingSent();
    }
}

Run the suite with php artisan test. Additional tests should cover HTTP 429, a connection exception, malformed JSON, an empty detection list, redirects, and anonymous access.

Operate it safely in production

Store the token in the deployment platform’s secret manager, not in committed .env files. After changing configuration, rebuild the cache with php artisan config:cache. Deployments should fail early if the token is absent; a small boot-time configuration check is preferable to discovering the problem during a client meeting.

Track structured counts for successful reports, terminal failure kinds, upstream status codes, retries, and latency. Alert on sustained authentication failures because they commonly indicate a revoked or incorrectly deployed token. Treat repeated 429 responses as a capacity or traffic-control signal, not as permission to add aggressive retries.

Common failures have distinct remedies:

  • 401 or 403: verify the service-scoped token, plan activation, secret injection, and configuration cache. Do not retry blindly.
  • 400 or 422: inspect URL validation and the documented request contract. Retrying an unchanged payload will not help.
  • 429: reduce traffic, preserve route throttling, respect the bounded retry delay, and review the active plan.
  • Timeouts or 5xx responses: retain the bounded retry policy and let the estimator retry later rather than occupying workers indefinitely.
  • Malformed successful response: record a protocol failure without logging the body, compare a sanitized response with the official documentation, and update the boundary mapper deliberately.
  • Unexpectedly sparse detections: review the supplied evidence and the public page manually. Absence of a detection is not proof that a technology is absent.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active.
  • The service-scoped token comes from the documentation page’s Service token panel.
  • No credential appears in source control, fixtures, logs, screenshots, or URLs.
  • The request uses the exact POST endpoint and sends JSON containing url.
  • Authentication, validation, quota, connection, server, and protocol failures are distinguishable.
  • Connection and response timeouts are bounded, and only temporary failures are retried.
  • Detections, confidence, evidence, versions, and redirects survive domain mapping.
  • The route requires an authenticated user and has an application-level throttle.
  • Tests use Http::fake() and make no real external calls.
  • Production configuration has been cached and a real preflight has been reviewed manually.

A useful redesign quote is not produced by multiplying a technology count by an hourly rate. It comes from turning hidden implementation details into explicit questions: what must be migrated, what can be preserved, what needs verification, and where evidence is weak. Automating that first pass gives developers more time for those judgments—and gives clients a quote grounded in the site they actually have.

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.