Tutorials

Native PHP: Detect Client Website Stack for Smarter Redesign Quotes

Native PHP: Detect Client Website Stack for Smarter Redesign Quotes

A redesign quote becomes risky when “a fairly simple site” turns out to contain a page builder, an aging commerce plugin, several analytics tags, and a redirect chain nobody documented. Visual scope is only half the estimate. The existing stack determines migration effort, testing depth, content extraction options, and the likelihood of unpleasant surprises.

This tutorial builds a Native PHP 8.3 command-line tool that sends a client’s public URL to the Website Technology Detector API, maps its confidence-scored findings into domain objects, and produces structured evidence for quote preparation. The integration uses native cURL, bounded retries, defensive response handling, PHPUnit tests, and environment-backed secrets.

Get access and copy the service token

Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.

  1. Open the Website Technology Detector service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store it in the project’s environment configuration, never in PHP source code.

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 query parameters commonly appear in access logs and monitoring records. Send only one authentication mechanism per request.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed environment, restart affected processes, verify the new token, and only then consider the rotation complete.

Confirm the exact API call

The integration uses POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON request body contains one required integration value: url.

Run this minimal request after replacing the placeholder in your shell environment:

export WEBSITE_TECH_DETECTOR_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --connect-timeout 5 \
  --max-time 20 \
  -X POST \
  'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies' \
  -H "Authorization: Bearer ${WEBSITE_TECH_DETECTOR_TOKEN}" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://www.example.com"}'

A successful response contains technology detections with confidence, supporting evidence, version information where available, and redirect information. Do not assume that every technology exposes a version, that evidence is always a scalar, or that future responses contain only fields your application already understands.

Shape the project around one boundary

The tool has three layers: a transport responsible for HTTP mechanics, a client responsible for authentication and retry policy, and domain objects responsible for normalizing the response. The quote workflow consumes domain objects instead of depending on raw JSON.

stack-inspector/
├── bin/inspect.php
├── src/
│   ├── Contract.php
│   ├── CurlTransport.php
│   ├── DetectorClient.php
│   └── DetectionReport.php
├── tests/DetectorClientTest.php
├── composer.json
└── .env.local

Create composer.json with PHP 8.3, the cURL extension, PSR-4 autoloading, and PHPUnit 11:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "QuoteStack\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "QuoteStack\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit tests"
  }
}

Run composer install. For local development, place this in an uncommitted .env.local file:

export WEBSITE_TECH_DETECTOR_TOKEN='YOUR_SERVICE_TOKEN'
export WEBSITE_TECH_CONNECT_TIMEOUT_MS='5000'
export WEBSITE_TECH_RESPONSE_TIMEOUT_MS='20000'

Load it with set -a; . ./.env.local; set +a. Add .env.local to .gitignore. Native PHP does not automatically parse environment files; production should inject the same variables through the process manager, container platform, or secret manager.

Implement a bounded native cURL transport

The transport does not know what a technology detection means. It returns status, headers, and body while enforcing TLS verification, connection and total timeouts, and a two-megabyte response limit.

<?php
// src/Contract.php
declare(strict_types=1);

namespace QuoteStack;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

interface Transport
{
    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $responseTimeoutMs,
    ): HttpResponse;
}

final class TransportException extends \RuntimeException {}

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        string $message = 'Technology detection failed',
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
<?php
// src/CurlTransport.php
declare(strict_types=1);

namespace QuoteStack;

final class CurlTransport implements Transport
{
    public function __construct(private int $maxBytes = 2_097_152) {}

    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $responseTimeoutMs,
    ): HttpResponse {
        $curl = curl_init($url);
        if ($curl === false) {
            throw new TransportException('Could not initialize cURL');
        }

        $responseHeaders = [];
        $responseBody = '';
        $tooLarge = false;

        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $responseTimeoutMs,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function ($handle, string $line)
                use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return $length;
            },
            CURLOPT_WRITEFUNCTION => function ($handle, string $chunk)
                use (&$responseBody, &$tooLarge): int {
                if (strlen($responseBody) + strlen($chunk) > $this->maxBytes) {
                    $tooLarge = true;
                    return 0;
                }
                $responseBody .= $chunk;
                return strlen($chunk);
            },
        ]);

        $result = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        $error = curl_error($curl);
        curl_close($curl);

        if ($tooLarge) {
            throw new TransportException('API response exceeded the size limit');
        }
        if ($result === false) {
            throw new TransportException('cURL request failed: ' . $error);
        }

        return new HttpResponse($status, $responseHeaders, $responseBody);
    }
}

Normalize uncertain response data at the boundary

The API’s contract describes detections, confidence, evidence, versions, and redirects. The mapper below deliberately avoids assuming one top-level envelope. It walks the decoded payload, recognizes detection-shaped objects, preserves evidence and version values without coercing their meaning, collects redirect-related structures, and retains the raw response for controlled diagnostics.

<?php
// src/DetectionReport.php
declare(strict_types=1);

namespace QuoteStack;

final readonly class TechnologyDetection implements \JsonSerializable
{
    public function __construct(
        public string $technology,
        public float $confidence,
        public array $evidence,
        public array $versions,
    ) {}

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

final readonly class DetectionReport implements \JsonSerializable
{
    public function __construct(
        public array $detections,
        public array $redirectInformation,
        public array $raw,
    ) {}

    public static function fromApi(array $payload): self
    {
        $detections = [];
        $redirects = [];
        self::walk($payload, $detections, $redirects);

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

    private static function walk(
        array $node,
        array &$detections,
        array &$redirects,
    ): void {
        $name = $node['technology'] ?? $node['name'] ?? null;
        $confidence = $node['confidence'] ?? null;

        if (is_string($name) && $name !== '' && is_numeric($confidence)) {
            $detections[] = new TechnologyDetection(
                $name,
                (float) $confidence,
                self::values($node['evidence'] ?? null),
                self::values($node['versions'] ?? $node['version'] ?? null),
            );
        }

        foreach ($node as $key => $value) {
            if (is_string($key)
                && str_contains(strtolower($key), 'redirect')
                && is_array($value)
            ) {
                $redirects[$key] = $value;
            }
            if (is_array($value)) {
                self::walk($value, $detections, $redirects);
            }
        }
    }

    private static function values(mixed $value): array
    {
        if ($value === null || $value === '') {
            return [];
        }
        return is_array($value) ? array_values($value) : [$value];
    }

    public function jsonSerialize(): array
    {
        return [
            'detections' => $this->detections,
            'redirect_information' => $this->redirectInformation,
        ];
    }
}

Confidence remains the numeric value returned by the service. The code does not silently reinterpret it as a percentage or probability. Quote policy should use thresholds only after confirming the documented scale and testing representative responses.

Add authentication, retries, and failure categories

Transient transport failures, 429, and selected gateway or availability failures can be retried. Invalid requests and authentication failures should return immediately: repeating the same credential or payload only wastes time and quota.

<?php
// src/DetectorClient.php
declare(strict_types=1);

namespace QuoteStack;

final class DetectorClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    private \Closure $sleep;

    public function __construct(
        private Transport $transport,
        private string $token,
        private int $connectTimeoutMs = 5000,
        private int $responseTimeoutMs = 20000,
        private int $maxAttempts = 3,
        ?\Closure $sleep = null,
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Service token is missing');
        }
        $this->sleep = $sleep ?? static fn (int $ms) => usleep($ms * 1000);
    }

    public function detect(string $targetUrl): DetectionReport
    {
        $this->assertAllowedUrl($targetUrl);
        $body = json_encode(['url' => $targetUrl], JSON_THROW_ON_ERROR);

        for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
            try {
                $response = $this->transport->post(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: application/json',
                        'Content-Type: application/json',
                    ],
                    $body,
                    $this->connectTimeoutMs,
                    $this->responseTimeoutMs,
                );
            } catch (TransportException $exception) {
                if ($attempt === $this->maxAttempts) {
                    throw new ApiFailure(
                        'transport',
                        null,
                        'Transport attempts exhausted',
                        $exception,
                    );
                }
                ($this->sleep)($this->backoffMs($attempt, null));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $payload = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );
                } catch (\JsonException $exception) {
                    throw new ApiFailure(
                        'invalid_response',
                        $response->status,
                        'API returned malformed JSON',
                        $exception,
                    );
                }

                if (!is_array($payload)) {
                    throw new ApiFailure(
                        'invalid_response',
                        $response->status,
                        'API response was not a JSON object or array',
                    );
                }

                return DetectionReport::fromApi($payload);
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new ApiFailure('authentication', $response->status);
            }

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

            if (!$retryable) {
                throw new ApiFailure('request', $response->status);
            }

            if ($attempt === $this->maxAttempts) {
                $kind = $response->status === 429 ? 'rate_limit' : 'upstream';
                throw new ApiFailure($kind, $response->status);
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            ($this->sleep)($this->backoffMs($attempt, $retryAfter));
        }

        throw new ApiFailure('internal');
    }

    private function backoffMs(int $attempt, mixed $retryAfter): int
    {
        if (is_string($retryAfter) && ctype_digit($retryAfter)) {
            return min(5000, (int) $retryAfter * 1000);
        }
        return min(2000, 200 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }

    private function assertAllowedUrl(string $url): void
    {
        $parts = parse_url($url);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower((string) ($parts['host'] ?? ''));

        if (!filter_var($url, FILTER_VALIDATE_URL)
            || !in_array($scheme, ['http', 'https'], true)
            || $host === ''
            || isset($parts['user'])
            || isset($parts['pass'])
            || $host === 'localhost'
            || str_ends_with($host, '.local')
        ) {
            throw new \InvalidArgumentException('A public HTTP(S) URL is required');
        }

        if (filter_var($host, FILTER_VALIDATE_IP)
            && !filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
            )
        ) {
            throw new \InvalidArgumentException('Private IP targets are not allowed');
        }
    }
}

Connect the detector to the quote workflow

The command prints normalized JSON that can be attached to a quote record or reviewed by the estimator. It logs only operational metadata: a correlation identifier, hostname, duration, count, and failure category. Tokens, response bodies, evidence, and complete URLs stay out of logs.

<?php
// bin/inspect.php
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use QuoteStack\ApiFailure;
use QuoteStack\CurlTransport;
use QuoteStack\DetectorClient;

$url = $argv[1] ?? '';
$token = getenv('WEBSITE_TECH_DETECTOR_TOKEN') ?: '';
$connectMs = (int) (getenv('WEBSITE_TECH_CONNECT_TIMEOUT_MS') ?: 5000);
$responseMs = (int) (getenv('WEBSITE_TECH_RESPONSE_TIMEOUT_MS') ?: 20000);
$requestId = bin2hex(random_bytes(8));
$started = hrtime(true);

try {
    $client = new DetectorClient(
        new CurlTransport(),
        $token,
        $connectMs,
        $responseMs,
    );
    $report = $client->detect($url);

    error_log(json_encode([
        'event' => 'technology_detection_completed',
        'request_id' => $requestId,
        'host' => parse_url($url, PHP_URL_HOST),
        'detections' => count($report->detections),
        'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
    ], JSON_THROW_ON_ERROR));

    echo json_encode(
        $report,
        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
    ) . PHP_EOL;
} catch (ApiFailure | \InvalidArgumentException $exception) {
    error_log(json_encode([
        'event' => 'technology_detection_failed',
        'request_id' => $requestId,
        'kind' => $exception instanceof ApiFailure
            ? $exception->kind
            : 'validation',
        'status' => $exception instanceof ApiFailure
            ? $exception->status
            : null,
    ], JSON_THROW_ON_ERROR));
    fwrite(STDERR, "Inspection failed; reference {$requestId}." . PHP_EOL);
    exit(1);
}

Run php bin/inspect.php https://www.example.com. The report can inform questions about CMS migration, server-side rendering, commerce integrations, analytics replacement, redirect preservation, and technologies whose missing versions require manual investigation. Detection is evidence for scoping, not permission to infer private infrastructure or a substitute for stakeholder discovery.

Test without calling the paid service

A fake transport makes retry and mapping behavior deterministic. Tests must never contain a real token or contact the production endpoint.

<?php
// tests/DetectorClientTest.php
declare(strict_types=1);

namespace QuoteStack\Tests;

use PHPUnit\Framework\TestCase;
use QuoteStack\ApiFailure;
use QuoteStack\DetectorClient;
use QuoteStack\HttpResponse;
use QuoteStack\Transport;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $responseTimeoutMs,
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class DetectorClientTest extends TestCase
{
    public function testRetriesAndMapsContractData(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(503, [], '{}'),
            new HttpResponse(200, [], json_encode([
                'result' => [
                    'detections' => [[
                        'technology' => 'Example CMS',
                        'confidence' => 0.94,
                        'evidence' => ['generator metadata'],
                        'versions' => ['8'],
                    ]],
                    'redirects' => [
                        ['from' => 'http://example.test',
                         'to' => 'https://example.test'],
                    ],
                ],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new DetectorClient(
            $transport,
            'test-token',
            maxAttempts: 2,
            sleep: static fn (int $ms) => null,
        );

        $report = $client->detect('https://example.test');

        self::assertSame(2, $transport->calls);
        self::assertCount(1, $report->detections);
        self::assertSame(
            'Example CMS',
            $report->detections[0]->technology,
        );
        self::assertNotEmpty($report->redirectInformation);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(401, [], '{}'),
        ]);
        $client = new DetectorClient($transport, 'bad-token');

        try {
            $client->detect('https://example.test');
            self::fail('Expected ApiFailure');
        } catch (ApiFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $transport->calls);
        }
    }
}

Run composer test. Add fixtures copied from approved, sanitized API responses as the contract evolves, particularly for empty detections, missing versions, multiple redirects, unusual evidence structures, malformed JSON, timeouts, and rate limiting.

Production decisions that protect the quote process

Security and privacy

Accept only public HTTP or HTTPS targets, authorize who may submit inspections, and rate-limit your own route or command runner. The local validation blocks obvious private literal addresses, but DNS names can change resolution; the remote service must remain responsible for safely fetching only public websites. Never expose the detector as an unrestricted public proxy.

Restrict secret visibility to the application process, rotate deliberately, and redact authorization headers everywhere. Retain raw responses only when necessary, with access controls and a deletion policy. A client URL can itself be commercially sensitive before a redesign is announced.

Observability and quotas

Track success, duration, failure category, HTTP status, retry count, and detection count. Alert on sustained authentication failures because they often indicate an expired or regenerated token. Treat repeated 429 responses as capacity feedback, not as permission to retry forever. If quote preparation becomes interactive, surface a “temporarily unavailable” state and allow a deliberate later retry.

Deployment

Verify PHP 8.3 or newer, ext-curl, CA certificates, outbound HTTPS access, Composer’s optimized autoloader, and injected environment variables. Keep connection and total timeout values below the calling worker’s own execution limit. If inspections move into background jobs later, preserve idempotent job identifiers and cap redelivery attempts; adding a queue is unnecessary for this focused command-line workflow.

Common failures and their meaning

  • 401 or 403: the token is missing, invalid, scoped incorrectly, or was revoked by regeneration. Do not retry automatically.
  • 400-class request failure: verify the public URL and JSON request. Repeating an unchanged invalid request cannot repair it.
  • 429: respect a numeric Retry-After value within a local cap, then stop after the bounded attempt count.
  • 502, 503, or 504: use short exponential backoff with jitter and preserve a structured upstream failure if attempts are exhausted.
  • Malformed or oversized JSON: fail closed. Do not turn an uncertain response into an empty, apparently successful report.
  • No detected technologies: record that discovery was inconclusive and schedule manual inspection rather than assuming the website has no meaningful stack.

Final verification checklist

  • The exact POST endpoint is used with a JSON url value.
  • The service-scoped token comes from the environment and never appears in logs or fixtures.
  • Connection, response, body-size, retry, and backoff limits are bounded.
  • Authentication and validation failures are not retried.
  • Detections preserve confidence, evidence, and versions without fabricated defaults.
  • Redirect information is retained for migration planning.
  • PHPUnit passes entirely through the fake transport.
  • A real smoke test produces a report for an authorized public client URL.
  • The estimator reviews low-evidence and missing-version findings before pricing the redesign.

A strong redesign quote does not pretend uncertainty has disappeared. It makes uncertainty visible early enough to price discovery, migration, and testing honestly. By keeping the API boundary strict and the resulting evidence readable, this small PHP tool turns an opaque website into a better technical conversation—and a quote that is far less likely to unravel after work begins.

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.