Symfony и ВИ: Ревизија на веб-страниците на клиентите за понуди за редизајн со API за детектор на технологии
A redesign quote built from screenshots alone is fragile. The visible pages may look simple while the underlying site contains a CMS, analytics tags, multiple JavaScript frameworks, legacy plugins, and redirects that complicate migration. Discovering those details after agreeing on a price turns estimation into damage control.
This tutorial builds a production-oriented Symfony command that inspects a public client website before a quote is prepared. It calls the Website Technology Detector API, converts its response into application-owned objects, and prints a concise stack report. The integration includes bounded timeouts, selective retries, structured failures, safe logging, and deterministic tests.
Get access and copy a service-scoped token
Start by creating 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 shown there.
- Store that token in environment-backed project configuration.
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 it keeps the credential out of URLs, proxy access logs, browser history, and analytics systems.
Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed environment that uses the service, verify the new token, and only then consider the rotation complete.
Confirm the exact API call
The detector uses this exact request:
POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies
It expects a JSON body containing url. Before writing Symfony code, make one minimal request from a trusted terminal:
curl --request POST \
--url '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"}'
Do not paste the real token into shell history on a shared machine. An environment variable or a secret-aware terminal workflow is safer.
Create the Symfony project
The feature is intentionally synchronous. A developer runs it while qualifying a redesign request, so adding Messenger and a queue would introduce operational machinery without improving the workflow. If audits later become a bulk or scheduled process, the same client can be called from a Messenger handler.
composer create-project symfony/skeleton quote-auditor
cd quote-auditor
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
The relevant project structure is small:
quote-auditor/
├── config/
│ └── services.yaml
├── src/
│ ├── Command/AuditWebsiteCommand.php
│ └── WebsiteAudit/
│ ├── AuditApiException.php
│ ├── AuditMapper.php
│ ├── TechnologyDetection.php
│ ├── WebsiteAudit.php
│ └── WebsiteTechnologyClient.php
└── tests/
└── WebsiteAudit/WebsiteTechnologyClientTest.php
Keep credentials in environment configuration
Put local secrets in .env.local, which should not be committed:
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_DETECTOR_ENDPOINT=https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies
Bind those values to the client through config/services.yaml:
parameters:
website_detector.endpoint: '%env(string:WEBSITE_DETECTOR_ENDPOINT)%'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\WebsiteAudit\WebsiteTechnologyClient:
arguments:
$endpoint: '%website_detector.endpoint%'
$token: '%env(string:WEBSITE_DETECTOR_TOKEN)%'
In production, inject the same variable names through the hosting platform’s secret store. The endpoint is configurable for testability and operational control, but deployments should use the exact official HTTPS endpoint.
Own the response at the application boundary
External JSON should not leak throughout the application. The detector returns confidence-scored detections, supporting evidence, version information, and redirects, but every value still needs type checks. Missing optional data should produce an incomplete report, not a PHP type error.
Create TechnologyDetection.php and WebsiteAudit.php:
<?php
// src/WebsiteAudit/TechnologyDetection.php
namespace App\WebsiteAudit;
final readonly class TechnologyDetection
{
public function __construct(
public string $name,
public ?float $confidence,
public array $versions,
public array $evidence,
) {}
}
// src/WebsiteAudit/WebsiteAudit.php
namespace App\WebsiteAudit;
final readonly class WebsiteAudit
{
/** @param list<TechnologyDetection> $detections */
public function __construct(
public array $detections,
public array $redirects,
) {}
}
The mapper below validates the conceptual response fields defensively. Redirect entries remain opaque because the quoting command only needs to flag their presence; code that later displays individual hops should introduce another DTO based on the exact shape documented by the service.
<?php
// src/WebsiteAudit/AuditMapper.php
namespace App\WebsiteAudit;
final class AuditMapper
{
public function fromPayload(array $payload): WebsiteAudit
{
$rows = $payload['detections'] ?? [];
$rows = is_array($rows) ? $rows : [];
$detections = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$name = $row['name'] ?? null;
if (!is_string($name) || trim($name) === '') {
continue;
}
$confidence = $row['confidence'] ?? null;
$confidence = is_numeric($confidence)
? max(0.0, min(100.0, (float) $confidence))
: null;
$versions = $this->listValue($row['versions'] ?? []);
$evidence = $this->listValue($row['evidence'] ?? []);
$detections[] = new TechnologyDetection(
trim($name),
$confidence,
$versions,
$evidence,
);
}
$redirects = $payload['redirects'] ?? [];
return new WebsiteAudit(
$detections,
is_array($redirects) ? array_values($redirects) : [],
);
}
private function listValue(mixed $value): array
{
if (is_string($value) || is_numeric($value)) {
return [(string) $value];
}
return is_array($value) ? array_values($value) : [];
}
}
Keeping assumptions in one mapper is a practical maintenance decision. If the official documentation evolves, one boundary changes while the command and quoting workflow remain stable.
Build a resilient HTTP client
Failures need categories that an operator can act on. Authentication failures require token correction, validation failures require input correction, and temporary upstream failures may justify a retry.
<?php
// src/WebsiteAudit/AuditApiException.php
namespace App\WebsiteAudit;
final class AuditApiException extends \RuntimeException
{
public function __construct(
public readonly string $category,
public readonly ?int $status = null,
?\Throwable $previous = null,
) {
parent::__construct(
"Website audit failed: {$category}",
0,
$previous,
);
}
}
The client uses short connection and overall response limits. It retries only transport failures, rate limiting, and selected temporary server failures. Validation and authentication responses are returned immediately because repeating the same invalid request cannot repair them.
<?php
// src/WebsiteAudit/WebsiteTechnologyClient.php
namespace App\WebsiteAudit;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class WebsiteTechnologyClient
{
public function __construct(
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
private readonly AuditMapper $mapper,
private readonly string $endpoint,
private readonly string $token,
) {}
public function inspect(string $url): WebsiteAudit
{
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new AuditApiException('invalid_url');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', $this->endpoint, [
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 5.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
$body = $response->getContent(false);
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode(
$body,
true,
512,
JSON_THROW_ON_ERROR,
);
} catch (\JsonException $exception) {
throw new AuditApiException(
'invalid_json',
$status,
$exception,
);
}
if (!is_array($payload)) {
throw new AuditApiException('invalid_payload', $status);
}
return $this->mapper->fromPayload($payload);
}
if ($status === 401 || $status === 403) {
throw new AuditApiException('authentication', $status);
}
if ($status === 400 || $status === 422) {
throw new AuditApiException('validation', $status);
}
$retryable = in_array(
$status,
[429, 502, 503, 504],
true,
);
if (!$retryable || $attempt === 3) {
$category = $status === 429
? 'rate_limited'
: 'upstream_http';
throw new AuditApiException($category, $status);
}
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new AuditApiException(
'transport',
null,
$exception,
);
}
}
$this->logger->warning('Website audit request will retry', [
'host' => parse_url($url, PHP_URL_HOST),
'attempt' => $attempt,
]);
usleep(200_000 * (2 ** ($attempt - 1)));
}
throw new AuditApiException('unexpected');
}
}
The backoff is deliberately bounded. A command should not hang indefinitely when a quota is exhausted or the provider is unavailable. For longer delays, especially a large Retry-After value, stop and schedule a later run instead of keeping a PHP worker occupied.
Expose the audit as a quoting command
<?php
// src/Command/AuditWebsiteCommand.php
namespace App\Command;
use App\WebsiteAudit\AuditApiException;
use App\WebsiteAudit\WebsiteTechnologyClient;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'quote:audit-website',
description: 'Inspect a public website before preparing a redesign quote',
)]
final class AuditWebsiteCommand extends Command
{
public function __construct(
private readonly WebsiteTechnologyClient $client,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('url', InputArgument::REQUIRED, 'Public website URL');
}
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
$io = new SymfonyStyle($input, $output);
$url = (string) $input->getArgument('url');
try {
$audit = $this->client->inspect($url);
} catch (AuditApiException $exception) {
$io->error(sprintf(
'Audit unavailable (%s%s).',
$exception->category,
$exception->status ? ', HTTP '.$exception->status : '',
));
return Command::FAILURE;
}
$rows = [];
foreach ($audit->detections as $detection) {
$rows[] = [
$detection->name,
$detection->confidence === null
? 'unknown'
: $detection->confidence.'%',
$detection->versions === []
? 'not reported'
: implode(', ', $detection->versions),
count($detection->evidence),
];
}
$io->table(
['Technology', 'Confidence', 'Version', 'Evidence items'],
$rows,
);
$io->note(sprintf(
'%d redirect record(s) reported.',
count($audit->redirects),
));
return Command::SUCCESS;
}
}
Run it with php bin/console quote:audit-website https://example.com. The results are inputs to professional judgment, not an automatic price calculator. Low-confidence detections deserve manual confirmation; redirects can signal domain consolidation work; version evidence may reveal migration risk without proving that a component is vulnerable.
Test without calling the live service
MockHttpClient makes retries and mappings deterministic. These tests never consume quota and never contain a real token.
<?php
// tests/WebsiteAudit/WebsiteTechnologyClientTest.php
namespace App\Tests\WebsiteAudit;
use App\WebsiteAudit\AuditApiException;
use App\WebsiteAudit\AuditMapper;
use App\WebsiteAudit\WebsiteTechnologyClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class WebsiteTechnologyClientTest extends TestCase
{
public function testMapsSuccessfulDetection(): void
{
$http = new MockHttpClient(new MockResponse(json_encode([
'detections' => [[
'name' => 'Example CMS',
'confidence' => 92,
'versions' => ['1.2'],
'evidence' => ['public markup signature'],
]],
'redirects' => [],
], JSON_THROW_ON_ERROR), ['http_code' => 200]));
$client = new WebsiteTechnologyClient(
$http,
new NullLogger(),
new AuditMapper(),
'https://service.invalid/detect',
'test-token',
);
$audit = $client->inspect('https://example.com');
self::assertCount(1, $audit->detections);
self::assertSame('Example CMS', $audit->detections[0]->name);
self::assertSame(92.0, $audit->detections[0]->confidence);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$requests = 0;
$http = new MockHttpClient(function () use (&$requests) {
$requests++;
return new MockResponse('', ['http_code' => 401]);
});
$client = new WebsiteTechnologyClient(
$http,
new NullLogger(),
new AuditMapper(),
'https://service.invalid/detect',
'invalid-token',
);
try {
$client->inspect('https://example.com');
self::fail('Expected an authentication failure.');
} catch (AuditApiException $exception) {
self::assertSame('authentication', $exception->category);
self::assertSame(1, $requests);
}
}
}
Run php bin/phpunit. A further retry test can queue two 503 responses followed by a successful response, although production test suites should inject a sleeper abstraction so backoff tests do not actually pause.
Security, observability, and deployment
Only audit public client URLs that you are authorized to inspect. If this client is later exposed through a web form, server-side request forgery becomes a serious concern: reject non-HTTP schemes, localhost, private and link-local addresses, and hosts that resolve into private networks. Revalidate after DNS resolution and redirects rather than trusting the submitted string alone.
Never log the authorization header, token, complete response body, or query-parameter credentials. The sample logs the target host, retry attempt, category, and status—enough for diagnosis without collecting unnecessary page evidence. Add a request correlation identifier if audits pass through multiple application components.
Deploy with optimized Composer dependencies, production environment variables, and writable Symfony cache and log directories. After rotating a token, restart long-running PHP processes so they reload environment-backed configuration.
Common failure modes
- 401 or 403: verify plan activation and the service-scoped token. A regenerated token invalidates the old one.
- 400 or 422: check that the request is JSON and contains a valid public
url. - 429: stop after bounded retries and run later or review plan capacity.
- Timeout or transport failure: distinguish temporary network trouble from a client site that is consistently unreachable.
- Empty detections: do not translate absence of evidence into “no technology.” Record the limitation and inspect manually.
- Unexpected JSON: fail at the mapper boundary, compare the payload shape with the official documentation, and update the adapter deliberately.
Final verification checklist
- The command sends
POSTto the exact detection endpoint with JSON containingurl. - The Bearer token comes from environment-backed configuration and never appears in source or logs.
- Connection and total response times are bounded.
- Authentication and validation failures are not retried.
- Rate limits and temporary upstream failures receive only bounded backoff.
- Detections, confidence, versions, evidence, and redirects are mapped defensively.
- Tests use
MockHttpClientand make no external requests. - A live smoke test succeeds in the production environment before the workflow is relied upon.
A technology report will not write a redesign quote for you, and that is precisely its value. It replaces guesses with inspectable evidence while leaving scope and pricing decisions with the developer. When discovery is repeatable, bounded, and honest about uncertainty, the resulting quote becomes easier to defend—and far less likely to hide an expensive surprise.