Native PHP 8.3: Automate Website Stack Audits for Redesign Quotes
A redesign quote can unravel when the “simple brochure site” turns out to contain a legacy CMS, several analytics products, a CDN, an embedded commerce system, and JavaScript dependencies nobody mentioned. Inspecting the stack before estimating the work replaces guesswork with evidence.
This tutorial builds a production-oriented Native PHP 8.3 command-line application that submits a client’s public URL to the Website Technology Detector API, maps the confidence-scored results into domain objects, and produces a reusable JSON audit for a redesign quote. The design keeps HTTP concerns isolated, handles transient failures deliberately, and remains straightforward to test and deploy.
Get access and copy the service token
First, create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login 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.
- Store it in the project’s environment configuration, never in PHP source code.
This service requires authentication; it has no tokenless mode. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps authentication out of URLs and access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update the secret everywhere the audit command runs, verify the new token, and only then remove any obsolete configuration.
Confirm the HTTP contract
The exact call is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON body contains url.
Before writing application code, make one minimal request. Replace the placeholder locally, but do not commit the resulting command to shell-history files or project documentation with a real token.
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"}'
The response contains technology detections with confidence, evidence, version information, and redirect information. Remote JSON must still be treated as untrusted input: fields may be absent, empty, or malformed during an upstream incident or future contract transition.
Shape the Native PHP project
The application is intentionally a CLI command. A developer preparing a quote can run it on demand, save the JSON beside other discovery notes, and avoid exposing a public endpoint that anyone could use to consume the account’s quota.
You need PHP 8.3 or newer, the cURL and JSON extensions, Composer, and PHPUnit 11 for tests. The API boundary has four layers: a cURL transport, a detector service with retry policy, domain response objects, and a small command adapter.
quote-stack-audit/
├── bin/audit-site
├── src/
│ ├── AuditReport.php
│ ├── DetectionResult.php
│ ├── HttpResponse.php
│ ├── Technology.php
│ ├── Transport.php
│ ├── TransportException.php
│ ├── CurlTransport.php
│ └── WebsiteDetector.php
├── tests/WebsiteDetectorTest.php
├── .env.example
├── .gitignore
├── composer.json
└── phpunit.xml
Create the dependency and environment files first:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"QuoteAudit\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"QuoteAudit\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit"
}
}
# .env.example
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
# .gitignore
.env.local
vendor/
build/
cp .env.example .env.local
composer install
Put the real token only in .env.local. The committed example contains a placeholder. Production should inject the same variable through the hosting platform’s secret manager rather than deploying a secret file.
Build a bounded cURL transport
The transport connects only to the fixed HTTPS API endpoint. It uses a three-second connection timeout and a fifteen-second total timeout, preserves response headers for rate-limit handling, and leaves certificate verification enabled.
<?php
// src/Transport.php
namespace QuoteAudit;
interface Transport
{
public function send(string $json, string $token): HttpResponse;
}
// src/HttpResponse.php
namespace QuoteAudit;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// src/TransportException.php
namespace QuoteAudit;
final class TransportException extends \RuntimeException {}
// src/CurlTransport.php
namespace QuoteAudit;
final class CurlTransport implements Transport
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';
public function send(string $json, string $token): HttpResponse
{
$handle = curl_init(self::ENDPOINT);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 15000,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$raw = curl_exec($handle);
if ($raw === false) {
throw new TransportException(curl_error($handle));
}
$status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($handle, CURLINFO_HEADER_SIZE);
$headerText = substr($raw, 0, $headerSize);
$body = substr($raw, $headerSize);
$headers = [];
foreach (preg_split('/\r\n|\r|\n/', $headerText) as $line) {
if (!str_contains($line, ':')) {
continue;
}
[$name, $value] = explode(':', $line, 2);
$headers[strtolower(trim($name))] = trim($value);
}
return new HttpResponse($status, $headers, $body);
}
}
Map remote JSON into domain results
A quote should not depend directly on an arbitrary response array. The domain model retains the useful concepts while allowing evidence, versions, and redirect information to preserve structured values instead of coercing them into misleading strings.
<?php
// src/Technology.php
namespace QuoteAudit;
final readonly class Technology implements \JsonSerializable
{
public function __construct(
public string $name,
public ?float $confidence,
public array $evidence,
public array $versions,
) {}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}
// src/AuditReport.php
namespace QuoteAudit;
final readonly class AuditReport implements \JsonSerializable
{
public function __construct(
public string $requestedUrl,
public array $technologies,
public array $redirectInformation,
) {}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}
// src/DetectionResult.php
namespace QuoteAudit;
final readonly class DetectionResult implements \JsonSerializable
{
private function __construct(
public bool $ok,
public ?AuditReport $report,
public ?string $error,
public ?int $httpStatus,
) {}
public static function success(AuditReport $report): self
{
return new self(true, $report, null, null);
}
public static function failure(string $error, ?int $status = null): self
{
return new self(false, null, $error, $status);
}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}
The service retries only transport errors, HTTP 408, HTTP 429, and server-side 5xx responses. Authentication and validation failures are not retried. Backoff is bounded, honors an integer Retry-After value when present, and adds small jitter otherwise.
<?php
// src/WebsiteDetector.php
namespace QuoteAudit;
final class WebsiteDetector
{
private readonly \Closure $sleep;
private readonly \Closure $log;
public function __construct(
private readonly Transport $transport,
private readonly string $token,
?\Closure $sleep = null,
?\Closure $log = null,
) {
$this->sleep = $sleep ?? static fn(int $ms) => usleep($ms * 1000);
$this->log = $log ?? static function (array $context): void {};
}
public function detect(string $url): DetectionResult
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if (
filter_var($url, FILTER_VALIDATE_URL) === false ||
!in_array($scheme, ['http', 'https'], true) ||
isset($parts['user']) ||
isset($parts['pass'])
) {
return DetectionResult::failure('invalid_url');
}
$body = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->send($body, $this->token);
} catch (TransportException $exception) {
($this->log)([
'event' => 'detector_transport_error',
'attempt' => $attempt,
'target_host' => $parts['host'] ?? null,
]);
if ($attempt === 3) {
return DetectionResult::failure('transport_error');
}
($this->sleep)($this->backoff($attempt, null));
continue;
}
($this->log)([
'event' => 'detector_response',
'attempt' => $attempt,
'status' => $response->status,
'target_host' => $parts['host'] ?? null,
]);
if ($response->status >= 200 && $response->status < 300) {
try {
$payload = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new \UnexpectedValueException();
}
return DetectionResult::success($this->map($url, $payload));
} catch (\JsonException|\UnexpectedValueException) {
return DetectionResult::failure(
'malformed_response',
$response->status
);
}
}
$retryable = $response->status === 408 ||
$response->status === 429 ||
$response->status >= 500;
if ($retryable && $attempt < 3) {
($this->sleep)($this->backoff(
$attempt,
$response->headers['retry-after'] ?? null
));
continue;
}
if (in_array($response->status, [401, 403], true)) {
return DetectionResult::failure(
'authentication_failed',
$response->status
);
}
if ($response->status === 429) {
return DetectionResult::failure(
'rate_limited',
$response->status
);
}
return DetectionResult::failure(
$response->status >= 500
? 'upstream_unavailable'
: 'request_rejected',
$response->status
);
}
return DetectionResult::failure('unexpected_failure');
}
private function map(string $url, array $payload): AuditReport
{
$rows = $this->findCollection($payload);
if ($rows === null) {
throw new \UnexpectedValueException();
}
$technologies = [];
foreach ($rows as $row) {
if (!is_array($row) || !is_string($row['name'] ?? null)) {
continue;
}
$confidence = $row['confidence'] ?? null;
$technologies[] = new Technology(
trim($row['name']),
is_numeric($confidence) ? (float) $confidence : null,
$this->asList($row['evidence'] ?? null),
$this->asList($row['versions'] ?? null),
);
}
if ($rows !== [] && $technologies === []) {
throw new \UnexpectedValueException();
}
return new AuditReport(
$url,
$technologies,
$this->collectRedirectInformation($payload)
);
}
private function findCollection(array $node): ?array
{
foreach ($node as $key => $value) {
if (
is_string($key) &&
in_array(strtolower($key), ['technologies', 'detections'], true) &&
is_array($value)
) {
return $value;
}
if (is_array($value)) {
$found = $this->findCollection($value);
if ($found !== null) {
return $found;
}
}
}
return null;
}
private function collectRedirectInformation(
array $node,
string $path = ''
): array {
$result = [];
foreach ($node as $key => $value) {
$current = $path === '' ? (string) $key : $path . '.' . $key;
if (is_string($key) && str_contains(strtolower($key), 'redirect')) {
$result[$current] = $value;
} elseif (is_array($value)) {
$result += $this->collectRedirectInformation($value, $current);
}
}
return $result;
}
private function asList(mixed $value): array
{
if ($value === null) {
return [];
}
return is_array($value) ? array_values($value) : [$value];
}
private function backoff(int $attempt, ?string $retryAfter): int
{
if ($retryAfter !== null && ctype_digit(trim($retryAfter))) {
return min(2000, (int) $retryAfter * 1000);
}
return min(2000, 250 * (2 ** ($attempt - 1)) + random_int(0, 100));
}
}
Add the audit command
The entry point loads a simple local environment file, writes structured operational events to standard error, and reserves standard output for the final JSON artifact. It never logs the token, response body, or complete client URL.
<?php
// bin/audit-site
declare(strict_types=1);
use QuoteAudit\CurlTransport;
use QuoteAudit\WebsiteDetector;
require dirname(__DIR__) . '/vendor/autoload.php';
function loadEnvironment(string $file): void
{
if (!is_file($file)) {
return;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $key)) {
continue;
}
$value = trim($value);
if (
strlen($value) >= 2 &&
(($value[0] === '"' && str_ends_with($value, '"')) ||
($value[0] === "'" && str_ends_with($value, "'")))
) {
$value = substr($value, 1, -1);
}
if (getenv($key) === false) {
putenv($key . '=' . $value);
}
}
}
loadEnvironment(dirname(__DIR__) . '/.env.local');
$url = $argv[1] ?? '';
$token = getenv('WEBSITE_DETECTOR_TOKEN');
if (!is_string($token) || $token === '' || $token === 'YOUR_SERVICE_TOKEN') {
fwrite(STDERR, "WEBSITE_DETECTOR_TOKEN is not configured.\n");
exit(78);
}
$logger = static function (array $context): void {
fwrite(STDERR, json_encode($context, JSON_UNESCAPED_SLASHES) . PHP_EOL);
};
$result = (new WebsiteDetector(
new CurlTransport(),
$token,
log: $logger
))->detect($url);
echo json_encode(
$result,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . PHP_EOL;
exit($result->ok ? 0 : 1);
Run it with a public client URL and save only successful output as a quote artifact:
chmod +x bin/audit-site
php bin/audit-site https://example.com > build/example-com-stack.json
Test retries without calling the service
A deterministic fake transport makes failure paths fast and quota-free. The most important tests prove that rate limits are retried, authentication failures are not, and malformed success bodies become structured failures.
<?php
// tests/WebsiteDetectorTest.php
namespace QuoteAudit\Tests;
use PHPUnit\Framework\TestCase;
use QuoteAudit\HttpResponse;
use QuoteAudit\Transport;
use QuoteAudit\WebsiteDetector;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function send(string $json, string $token): HttpResponse
{
return $this->responses[$this->calls++];
}
}
final class WebsiteDetectorTest extends TestCase
{
public function testRetriesRateLimitThenMapsDetection(): void
{
$fake = new FakeTransport([
new HttpResponse(429, ['retry-after' => '0'], '{}'),
new HttpResponse(200, [], json_encode([
'technologies' => [[
'name' => 'PHP',
'confidence' => 95,
'evidence' => ['response evidence'],
'versions' => ['8.3'],
]],
'redirects' => [],
], JSON_THROW_ON_ERROR)),
]);
$result = (new WebsiteDetector(
$fake,
'test-token',
static function (int $milliseconds): void {}
))->detect('https://example.com');
self::assertTrue($result->ok);
self::assertSame(2, $fake->calls);
self::assertSame('PHP', $result->report->technologies[0]->name);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new FakeTransport([
new HttpResponse(401, [], '{}'),
]);
$result = (new WebsiteDetector(
$fake,
'invalid-token',
static function (int $milliseconds): void {}
))->detect('https://example.com');
self::assertFalse($result->ok);
self::assertSame('authentication_failed', $result->error);
self::assertSame(1, $fake->calls);
}
public function testRejectsMalformedSuccessBody(): void
{
$fake = new FakeTransport([
new HttpResponse(200, [], '{"unexpected":true}'),
]);
$result = (new WebsiteDetector(
$fake,
'test-token'
))->detect('https://example.com');
self::assertSame('malformed_response', $result->error);
}
}
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="quote-audit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
composer test
Security, observability, and deployment
The submitted URL is disclosed to the external service, so avoid URLs containing private query parameters, preview tokens, customer identifiers, or credentials. This project accepts only HTTP and HTTPS URLs without embedded usernames or passwords. If the command becomes a web feature, add authorization, quotas, an approved-host policy, and background execution before exposing it.
Keep logs operational rather than forensic. Record the target hostname, attempt number, HTTP status, duration, and terminal failure category. Never record authorization headers or full response bodies. Monitor repeated authentication_failed, rate_limited, and upstream_unavailable outcomes separately because they demand different responses.
Deploy with PHP’s cURL and JSON extensions enabled, run composer install --no-dev --classmap-authoritative, and inject WEBSITE_DETECTOR_TOKEN from a secret store. Schedule token rotation deliberately because regeneration immediately invalidates the previous active token.
Common failures
- 401 or 403: verify the service-scoped token, plan activation, and deployment secret. Do not retry automatically.
- 429: respect the bounded retry policy, then defer the audit or review plan capacity.
- Malformed response: preserve the failure category and investigate without silently treating it as an empty technology list.
- Timeout or 5xx: allow the bounded retries to finish, then rerun later instead of blocking quote preparation indefinitely.
- Low-confidence evidence: include it in discovery notes, but confirm it manually before turning it into scope or pricing.
Final verification checklist
- The real token exists only in environment-backed configuration.
- The minimal API request succeeds with the exact POST endpoint.
composer testpasses without network access.- A successful audit contains mapped technologies, confidence, evidence, versions, and available redirect information.
- Authentication failures make one request; transient failures make no more than three.
- Logs contain no token, response body, or sensitive URL data.
- The generated JSON is reviewed alongside the website rather than treated as infallible scope.
A stack detector does not write the redesign quote for you. It does something more valuable: it turns the first technical conversation from speculation into an evidence-backed review. When the audit is bounded, testable, and explicit about uncertainty, a small PHP command becomes a dependable part of professional discovery rather than another fragile script.