Izvorni PHP 8.3: Redizajn automatskih ponuda otkrivanjem tehnologije klijentske web-stranice
A redesign estimate can go wrong before anyone opens a design tool. A brochure site built from a few templates is not the same job as a storefront, a heavily customized content system, or an application assembled behind several redirects. If the quote treats them alike, the missing discovery work usually reappears later as delay, scope conflict, or margin loss.
This tutorial builds a Native PHP 8.3 command-line tool that inspects a public client website with the Website Technology Detector API and turns the response into reviewable evidence for a redesign quote. It does not let an API dictate the price. It gives the person preparing the quote a consistent technical baseline: detections, confidence scores, evidence, version information, and response metadata such as redirect information.
Get access and verify the API
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, select an available Free, Plus, or Pro plan, and complete its activation. Then open the official service documentation. Find the Service token panel and copy the service-scoped token shown there.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The project below uses a Bearer token because headers are less likely than query strings to appear in access logs, browser history, or monitoring URLs. Regenerating the service token revokes the previously active token, so coordinate rotation with deployment rather than regenerating it casually.
Confirm the exact request
The API call is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON body contains url. Before writing the application, make one minimal request with a placeholder:
curl --request POST \
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Content-Type: application/json' \
--data '{"url":"https://example.com"}' \
--connect-timeout 5 \
--max-time 20
A successful response should be JSON. Do not build production logic by copying one sample response into assumptions: confidence, evidence, versions, and redirect information still need validation at the application boundary.
Store the real credential outside source control. For local command-line development, create an uncommitted .env.local shell file:
export WTD_API_TOKEN='YOUR_SERVICE_TOKEN'
Load it only into the current shell with set -a; . ./.env.local; set +a. Never commit that file, paste its value into fixtures, or print it during diagnostics.
Architecture: evidence first, pricing second
The useful boundary is small: a transport performs HTTP, a detector client owns authentication and retry policy, and a domain report prepares facts for quote review. Keeping these responsibilities separate makes the unreliable network portion replaceable in tests.
The mapper deliberately preserves unknown detection attributes and all top-level response metadata. That matters because technology identity and redirect details must not be guessed from undocumented field names. The supplied contract fields are normalized, while additional documented response data remains available to the reviewer.
Create the project with PHP 8.3, Composer, the cURL extension, and PHPUnit 11:
mkdir -p quote-inspector/src quote-inspector/bin quote-inspector/tests
cd quote-inspector
composer init --name=example/quote-inspector --no-interaction
composer require php:^8.3 ext-curl:*
composer require --dev phpunit/phpunit:^11.0
Use this composer.json configuration to load the project’s single boundary module:
{
"name": "example/quote-inspector",
"require": {
"php": "^8.3",
"ext-curl": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"files": [
"src/WebsiteTechnologyDetector.php"
]
}
}
Run composer dump-autoload, and add /.env.local and /vendor/ to .gitignore.
Implement the HTTP and domain boundary
Create src/WebsiteTechnologyDetector.php. The transport enforces HTTPS for the API, disables redirects so credentials cannot be forwarded elsewhere, retains TLS verification, limits response size, and applies bounded connection and total timeouts.
<?php
declare(strict_types=1);
namespace QuoteTool;
interface Transport
{
public function send(
string $method,
string $url,
array $headers,
string $body,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse;
}
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers = []
) {}
}
final class TransportException extends \RuntimeException {}
final class ServiceException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $status = null
) {
parent::__construct($message);
}
}
final class CurlTransport implements Transport
{
public function send(
string $method,
string $url,
array $headers,
string $body,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse {
$handle = curl_init($url);
if ($handle === false) {
throw new TransportException('Could not initialize cURL.');
}
$responseHeaders = [];
$responseBody = '';
$tooLarge = false;
curl_setopt_array($handle, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
$line = trim($line);
if ($line === '' || str_starts_with($line, 'HTTP/')) {
return $length;
}
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return $length;
},
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk)
use (&$responseBody, &$tooLarge): int {
if (strlen($responseBody) + strlen($chunk) > 2_000_000) {
$tooLarge = true;
return 0;
}
$responseBody .= $chunk;
return strlen($chunk);
},
]);
if (curl_exec($handle) === false) {
$message = $tooLarge
? 'API response exceeded two megabytes.'
: 'cURL failed with code ' . curl_errno($handle) . '.';
throw new TransportException($message);
}
return new HttpResponse(
(int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$responseBody,
$responseHeaders
);
}
}
final readonly class Detection
{
public function __construct(
public ?float $confidence,
public array $evidence,
public array $versions,
public array $attributes
) {}
}
final readonly class DetectionReport
{
public function __construct(
public array $detections,
public array $responseMetadata
) {}
public function forQuote(): array
{
$missingEvidence = 0;
$unknownVersions = 0;
$items = [];
foreach ($this->detections as $detection) {
$missingEvidence += $detection->evidence === [] ? 1 : 0;
$unknownVersions += $detection->versions === [] ? 1 : 0;
$items[] = [
'attributes' => $detection->attributes,
'confidence' => $detection->confidence,
'evidence' => $detection->evidence,
'versions' => $detection->versions,
];
}
$actions = [];
if ($items === []) {
$actions[] = 'Perform manual discovery; no detection is not proof of a simple stack.';
}
if ($missingEvidence > 0) {
$actions[] = 'Manually confirm detections that have no evidence.';
}
if ($unknownVersions > 0) {
$actions[] = 'Confirm versions before estimating migrations or upgrades.';
}
if ($this->responseMetadata !== []) {
$actions[] = 'Review response metadata, including redirect information, before scoping.';
}
return [
'detected_components' => count($items),
'evidence_missing' => $missingEvidence,
'versions_unknown' => $unknownVersions,
'detections' => $items,
'response_metadata' => $this->responseMetadata,
'review_actions' => $actions,
];
}
}
final class DetectorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';
private readonly \Closure $sleep;
private readonly \Closure $log;
public function __construct(
private readonly string $token,
private readonly Transport $transport,
?callable $sleep = null,
?callable $log = null
) {
if (trim($token) === '') {
throw new ServiceException('configuration', 'Service token is missing.');
}
$this->sleep = $sleep === null
? static fn (int $milliseconds) => usleep($milliseconds * 1000)
: \Closure::fromCallable($sleep);
$this->log = $log === null
? static fn (array $event) => null
: \Closure::fromCallable($log);
}
public function detect(string $targetUrl): DetectionReport
{
$parts = parse_url($targetUrl);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if (
!filter_var($targetUrl, FILTER_VALIDATE_URL)
|| !in_array($scheme, ['http', 'https'], true)
|| empty($parts['host'])
|| isset($parts['user'])
|| isset($parts['pass'])
|| strlen($targetUrl) > 2048
) {
throw new ServiceException('input', 'A valid public HTTP or HTTPS URL is required.');
}
$body = json_encode(
['url' => $targetUrl],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
$started = hrtime(true);
try {
$response = $this->transport->send(
'POST',
self::ENDPOINT,
[
'Authorization: Bearer ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
],
$body,
5_000,
20_000
);
} catch (TransportException $exception) {
($this->log)([
'event' => 'detector.transport_failure',
'attempt' => $attempt,
'target_host' => $parts['host'],
]);
if ($attempt === 3) {
throw new ServiceException(
'transport',
'Technology detection could not reach the service.'
);
}
($this->sleep)($this->delayMilliseconds($attempt, []));
continue;
}
($this->log)([
'event' => 'detector.response',
'attempt' => $attempt,
'status' => $response->status,
'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
'target_host' => $parts['host'],
'request_id' => $response->headers['x-request-id'] ?? null,
]);
if ($response->status >= 200 && $response->status < 300) {
return $this->mapResponse($response->body);
}
if (in_array($response->status, [400, 422], true)) {
throw new ServiceException(
'validation',
'The service rejected the submitted URL.',
$response->status
);
}
if (in_array($response->status, [401, 403], true)) {
throw new ServiceException(
'authentication',
'The service token was rejected.',
$response->status
);
}
$retryable = $response->status === 429
|| in_array($response->status, [502, 503, 504], true);
if ($retryable && $attempt < 3) {
($this->sleep)(
$this->delayMilliseconds($attempt, $response->headers)
);
continue;
}
$kind = $response->status === 429 ? 'rate_limit' : 'upstream';
throw new ServiceException(
$kind,
'Technology detection is temporarily unavailable.',
$response->status
);
}
throw new \LogicException('Retry loop ended unexpectedly.');
}
private function mapResponse(string $json): DetectionReport
{
try {
$payload = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new ServiceException('schema', 'The service returned invalid JSON.');
}
if (!is_array($payload) || !isset($payload['detections'])
|| !is_array($payload['detections'])) {
throw new ServiceException('schema', 'The detections collection is missing.');
}
$detections = [];
foreach ($payload['detections'] as $item) {
if (!is_array($item)) {
throw new ServiceException('schema', 'A detection has an invalid shape.');
}
$confidence = null;
if (array_key_exists('confidence', $item)) {
if (!is_numeric($item['confidence'])) {
throw new ServiceException('schema', 'A confidence score is invalid.');
}
$confidence = (float) $item['confidence'];
if (!is_finite($confidence)) {
throw new ServiceException('schema', 'A confidence score is not finite.');
}
}
$evidence = $this->asList($item['evidence'] ?? null);
$versions = $this->asList($item['versions'] ?? null);
unset($item['confidence'], $item['evidence'], $item['versions']);
$detections[] = new Detection(
$confidence,
$evidence,
$versions,
$item
);
}
unset($payload['detections']);
return new DetectionReport($detections, $payload);
}
private function asList(mixed $value): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
return [$value];
}
return array_is_list($value) ? $value : [$value];
}
private function delayMilliseconds(int $attempt, array $headers): int
{
$retryAfter = $headers['retry-after'] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
return min(5_000, (int) $retryAfter * 1000);
}
return $attempt === 1 ? 250 : 750;
}
}
The client retries only transport failures, rate limiting, and selected gateway or availability failures. Invalid input and rejected credentials will not improve with repetition. A numeric Retry-After value is respected but capped at five seconds, preventing a synchronous quoting command from hanging indefinitely.
Add the quote inspection command
Create bin/inspect-for-quote. Its logs contain the target host, timing, status, and optional request identifier, but omit the token, request body, response body, URL path, and query string.
#!/usr/bin/env php
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use QuoteTool\CurlTransport;
use QuoteTool\DetectorClient;
use QuoteTool\ServiceException;
$token = getenv('WTD_API_TOKEN');
$url = $argv[1] ?? '';
$logger = static function (array $event): void {
fwrite(
STDERR,
json_encode($event, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
. PHP_EOL
);
};
try {
if (!is_string($token) || trim($token) === '') {
throw new ServiceException(
'configuration',
'Set WTD_API_TOKEN before running the command.'
);
}
$report = (new DetectorClient($token, new CurlTransport(), log: $logger))
->detect($url);
fwrite(
STDOUT,
json_encode(
$report->forQuote(),
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . PHP_EOL
);
} catch (ServiceException $exception) {
fwrite(
STDERR,
json_encode([
'event' => 'quote_inspection_failed',
'kind' => $exception->kind,
'status' => $exception->status,
'message' => $exception->getMessage(),
], JSON_THROW_ON_ERROR) . PHP_EOL
);
exit(in_array($exception->kind, ['input', 'configuration'], true) ? 2 : 3);
}
Make it executable with chmod +x bin/inspect-for-quote, load the environment file, and run bin/inspect-for-quote https://client.example. The result is suitable for attaching to an internal quote worksheet. Keep human approval between this report and any promised price or schedule.
Test without calling the live service
A deterministic fake transport proves mapping and failure behavior without consuming quota or exposing a token. Create tests/DetectorClientTest.php:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use QuoteTool\DetectorClient;
use QuoteTool\HttpResponse;
use QuoteTool\ServiceException;
use QuoteTool\Transport;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function send(
string $method,
string $url,
array $headers,
string $body,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse {
return $this->responses[$this->calls++];
}
}
final class DetectorClientTest extends TestCase
{
public function testItMapsEvidenceForQuoteReview(): void
{
$fake = new FakeTransport([
new HttpResponse(200, json_encode([
'detections' => [[
'confidence' => 0.91,
'evidence' => ['response header'],
'versions' => ['8.3'],
]],
], JSON_THROW_ON_ERROR)),
]);
$report = (new DetectorClient('test-token', $fake))
->detect('https://example.com')
->forQuote();
self::assertSame(1, $report['detected_components']);
self::assertSame(0, $report['evidence_missing']);
self::assertSame(0, $report['versions_unknown']);
self::assertSame(1, $fake->calls);
}
public function testItDoesNotRetryRejectedCredentials(): void
{
$fake = new FakeTransport([
new HttpResponse(401, '{}'),
]);
try {
(new DetectorClient('bad-token', $fake))
->detect('https://example.com');
self::fail('Expected authentication failure.');
} catch (ServiceException $exception) {
self::assertSame('authentication', $exception->kind);
self::assertSame(401, $exception->status);
self::assertSame(1, $fake->calls);
}
}
public function testItRetriesRateLimitingThenSucceeds(): void
{
$delays = [];
$fake = new FakeTransport([
new HttpResponse(429, '{}', ['retry-after' => '0']),
new HttpResponse(200, '{"detections":[]}'),
]);
$client = new DetectorClient(
'test-token',
$fake,
static function (int $milliseconds) use (&$delays): void {
$delays[] = $milliseconds;
}
);
$report = $client->detect('https://example.com');
self::assertSame([], $report->detections);
self::assertSame(2, $fake->calls);
self::assertSame([0], $delays);
}
}
Run vendor/bin/phpunit tests. The token is deliberately fake because no test crosses the transport boundary.
Security, operations, and common failures
Treat submitted domains and resulting technology data as customer information. Restrict access to stored reports, define a retention period, and require that users inspect only sites they are authorized to assess. If your business accepts quotes for a known domain inventory, enforce that allowlist before calling the API.
In production, inject WTD_API_TOKEN through the hosting platform’s secret facility or the PHP process environment. Do not copy .env.local into an image. Ensure the cURL extension and CA certificate bundle are installed, preserve TLS verification, and restart long-lived PHP processes after changing injected secrets.
Watch structured counts for authentication failures, rate limits, transport failures, upstream status, and latency. Alert on sustained patterns rather than one transient request. Never log authorization headers or full response bodies merely to make debugging convenient.
- 401 or 403: confirm the service-scoped token, activation state, and environment injection. Do not retry. If the token was regenerated, the former value is already revoked.
- 400 or 422: check the submitted public HTTP or HTTPS URL. Retrying the same payload is wasteful.
- 429: preserve the structured failure after bounded retries. Queue the quote for later review instead of looping indefinitely.
- 502, 503, 504, or cURL failure: allow the bounded retries, then keep the quote in a recoverable “technical discovery pending” state.
- Empty detections: do not translate that into “no technology.” Protected, unusual, or minimally exposed sites still require manual discovery.
- Missing versions or evidence: price migrations conservatively and identify the unanswered question in the quote.
Final verification checklist
- Confirm the plan is active and the service token is available only through
WTD_API_TOKEN. - Run the minimal request against a public URL you are authorized to inspect.
- Run
vendor/bin/phpunit testsand verify all deterministic tests pass. - Run the quote command and inspect detections, confidence, evidence, versions, and response metadata.
- Confirm logs contain no token, response body, URL path, or query string.
- Exercise invalid URL, rejected-token, rate-limit, timeout, and malformed-response paths before deployment.
- Record unresolved evidence and redirect questions as discovery items rather than silently converting them into certainty.
The strongest redesign quote is not the one with the most automated detail. It is the one that distinguishes observed facts from assumptions. A narrow PHP boundary, defensive response mapping, bounded failure behavior, and an explicit human review step turn website detection into something more valuable than a technology list: a disciplined starting point for an honest scope.