Symfony: Анализа на веб-стек со вештачка интелигенција за точни понуди за редизајн
A redesign quote becomes risky when the visible pages hide the expensive parts: a legacy CMS, several analytics tags, an e-commerce layer, an external consent platform, or redirects that lead somewhere unexpected. Guessing from page markup is slow and inconsistent. A repeatable stack inspection gives the quoting team evidence it can review before estimating migration, integration, and testing work.
This tutorial builds a production-oriented Symfony command that submits a client’s public URL to the Website Technology Detector API, maps the response into domain objects, and prints an evidence-backed report. The integration includes bounded timeouts, selective retries, safe logging, automated tests, and deployment guidance.
Get access and create a service token
Register through the registration page, or use the sign-in page if you already have an account.
- Open the Website Technology Detector service page.
- Choose the available Free, Plus, or Pro plan and complete activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token.
- Store it in environment-backed Symfony configuration, never in PHP source, fixtures, screenshots, or committed environment files.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update every running instance and scheduled worker that uses the old value.
The API accepts a Bearer token, an X-API-Token header, or a token query parameter. This project uses the Bearer form because credentials in query strings are more likely to appear in access logs and monitoring URLs.
Verify the exact endpoint first
The integration sends POST requests to https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON body contains a single url value.
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"}'
This small request separates account or plan problems from application bugs. A failed authentication response here should be resolved before Symfony enters the picture.
Create the Symfony project
You need PHP 8.3 or later, Composer, and a public website that you are authorized to inspect. Symfony’s HTTP client provides transport abstraction and deterministic test doubles; Monolog supplies structured application logging.
composer create-project symfony/skeleton client-stack-inspector
cd client-stack-inspector
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
The project stays deliberately small. A console command fits a freelancer or compact development team: stack inspection happens during discovery, not inside a customer-facing request. Messenger would add operational cost without improving this short, synchronous workflow.
src/
Command/InspectClientStackCommand.php
TechnologyDetector/Detection.php
TechnologyDetector/DetectionReport.php
TechnologyDetector/DetectorException.php
TechnologyDetector/TechnologyDetectorClient.php
tests/
TechnologyDetector/TechnologyDetectorClientTest.php
Keep credentials in environment configuration
Add the token to .env.local, which should remain outside version control:
WEBSITE_TECHNOLOGY_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
Bind the value by constructor argument in config/services.yaml. Symfony can still autowire the HTTP client and logger by interface.
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $technologyDetectorToken: '%env(WEBSITE_TECHNOLOGY_DETECTOR_TOKEN)%'
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Kernel.php'
Map the API boundary into domain objects
External JSON must not flow unchecked into quoting logic. Detection details can be missing, nullable, or extended over time. The mapper therefore validates collections and scalar values, preserves structured evidence, and accepts either a direct response or a conventional data envelope. It also recognizes detection and technology collection names defensively instead of assuming that every successful payload is identical.
<?php
// src/TechnologyDetector/Detection.php
namespace App\TechnologyDetector;
final readonly class Detection
{
public function __construct(
public string $name,
public int|float|string|null $confidence,
public array $versions,
public array $evidence,
) {}
}
// src/TechnologyDetector/DetectionReport.php
namespace App\TechnologyDetector;
final readonly class DetectionReport
{
public function __construct(
public array $detections,
public array $redirects,
public array $raw,
) {}
public static function fromPayload(array $payload): self
{
$root = isset($payload['data']) && is_array($payload['data'])
? $payload['data']
: $payload;
$rows = $root['detections'] ?? $root['technologies'] ?? [];
$rows = is_array($rows) ? $rows : [];
$detections = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$name = $row['name'] ?? $row['technology'] ?? null;
if (!is_string($name) || trim($name) === '') {
continue;
}
$confidence = $row['confidence'] ?? null;
if (!is_int($confidence) && !is_float($confidence)
&& !is_string($confidence)) {
$confidence = null;
}
$versions = $row['versions'] ?? $row['version'] ?? [];
if (is_string($versions)) {
$versions = [$versions];
}
$versions = is_array($versions)
? array_values(array_filter($versions, 'is_string'))
: [];
$evidence = $row['evidence'] ?? [];
if (!is_array($evidence)) {
$evidence = [$evidence];
}
$detections[] = new Detection(
trim($name),
$confidence,
$versions,
$evidence,
);
}
$redirects = $root['redirects'] ?? [];
$redirects = is_array($redirects) ? array_values($redirects) : [];
return new self($detections, $redirects, $payload);
}
}
Preserving confidence as supplied avoids silently assuming whether its scale is fractional or percentage-based. Keeping raw data inside the report also helps diagnose schema changes, but the command will not log that data automatically because evidence can contain URLs or page-derived values.
Represent structured failures
<?php
// src/TechnologyDetector/DetectorException.php
namespace App\TechnologyDetector;
final class DetectorException extends \RuntimeException
{
public function __construct(
public readonly string $reason,
string $message,
public readonly ?int $httpStatus = null,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Build a bounded, retry-aware HTTP client
The detector operation is read-like even though its transport method is POST. Retrying can therefore be useful, but only for transport failures, rate limiting, and selected temporary server responses. Authentication and validation failures should fail immediately: another identical request cannot repair a revoked token or malformed URL.
<?php
// src/TechnologyDetector/TechnologyDetectorClient.php
namespace App\TechnologyDetector;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class TechnologyDetectorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';
public function __construct(
private HttpClientInterface $httpClient,
private string $technologyDetectorToken,
private LoggerInterface $logger,
) {}
public function detect(string $url): DetectionReport
{
$host = $this->validatePublicUrl($url);
$retryableStatuses = [429, 502, 503, 504];
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->httpClient->request('POST', self::ENDPOINT, [
'headers' => [
'Authorization' => 'Bearer '.$this->technologyDetectorToken,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 12.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if (in_array($status, $retryableStatuses, true) && $attempt < 3) {
$headers = $response->getHeaders(false);
$response->getContent(false);
$delayMs = 250 * (2 ** ($attempt - 1));
$retryAfter = trim($headers['retry-after'][0] ?? '');
if ($retryAfter !== '' && ctype_digit($retryAfter)) {
$delayMs = min(2000, ((int) $retryAfter) * 1000);
}
$this->logger->warning('Technology detection retry scheduled', [
'host' => $host,
'status' => $status,
'attempt' => $attempt,
'delay_ms' => $delayMs,
]);
usleep($delayMs * 1000);
continue;
}
$body = $response->getContent(false);
if ($status < 200 || $status >= 300) {
$reason = match ($status) {
401, 403 => 'authentication_failed',
429 => 'rate_limited',
default => $status >= 500
? 'upstream_unavailable'
: 'request_rejected',
};
throw new DetectorException(
$reason,
"Detector request failed with HTTP {$status}.",
$status,
);
}
try {
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new DetectorException(
'invalid_response',
'Detector returned invalid JSON.',
$status,
$exception,
);
}
if (!is_array($payload)) {
throw new DetectorException(
'invalid_response',
'Detector returned an unexpected JSON value.',
$status,
);
}
$report = DetectionReport::fromPayload($payload);
$this->logger->info('Technology detection completed', [
'host' => $host,
'detections' => count($report->detections),
'redirects' => count($report->redirects),
]);
return $report;
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new DetectorException(
'transport_failure',
'Detector could not be reached after three attempts.',
null,
$exception,
);
}
$delayMs = 250 * (2 ** ($attempt - 1));
$this->logger->warning('Technology detector transport failure', [
'host' => $host,
'attempt' => $attempt,
'delay_ms' => $delayMs,
]);
usleep($delayMs * 1000);
}
}
throw new DetectorException('unexpected_failure', 'Detection did not complete.');
}
private function validatePublicUrl(string $url): string
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true)
|| $host === ''
|| isset($parts['user'])
|| isset($parts['pass'])
|| $host === 'localhost') {
throw new DetectorException(
'invalid_url',
'Supply a public HTTP or HTTPS URL without embedded credentials.',
);
}
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 DetectorException('invalid_url', 'Private IP addresses are not allowed.');
}
return $host;
}
}
Timeouts bound both inactivity and total request duration. Backoff is capped, and a numeric Retry-After value is honored only within that cap. The logger records the hostname, attempt, status, and counts—not the token, authorization header, complete response, or page evidence.
Expose the workflow as a quoting command
<?php
// src/Command/InspectClientStackCommand.php
namespace App\Command;
use App\TechnologyDetector\DetectorException;
use App\TechnologyDetector\TechnologyDetectorClient;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:inspect-client-stack',
description: 'Inspect a public website before preparing a redesign quote.',
)]
final class InspectClientStackCommand extends Command
{
public function __construct(
private readonly TechnologyDetectorClient $detector,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('url', InputArgument::REQUIRED, 'Public client website URL');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$report = $this->detector->detect((string) $input->getArgument('url'));
} catch (DetectorException $exception) {
$output->writeln(sprintf(
'<error>Inspection failed [%s]: %s</error>',
$exception->reason,
$exception->getMessage(),
));
return Command::FAILURE;
}
$table = new Table($output);
$table->setHeaders(['Technology', 'Confidence', 'Version', 'Evidence']);
foreach ($report->detections as $detection) {
$table->addRow([
$detection->name,
$detection->confidence ?? 'unknown',
$detection->versions === [] ? 'unknown' : implode(', ', $detection->versions),
json_encode($detection->evidence, JSON_UNESCAPED_SLASHES),
]);
}
$table->render();
$output->writeln(sprintf('Redirect entries: %d', count($report->redirects)));
return Command::SUCCESS;
}
}
Run it immediately before discovery notes are converted into an estimate:
php bin/console app:inspect-client-stack https://example.com
A detection is evidence for investigation, not an automatic pricing rule. Low-confidence results, ambiguous versions, redirects, and third-party scripts should become questions for the client. They should not silently turn into billable assumptions.
Test without calling the live service
MockHttpClient keeps tests deterministic and confirms both the outgoing contract and boundary mapping.
<?php
// tests/TechnologyDetector/TechnologyDetectorClientTest.php
namespace App\Tests\TechnologyDetector;
use App\TechnologyDetector\DetectorException;
use App\TechnologyDetector\TechnologyDetectorClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class TechnologyDetectorClientTest extends TestCase
{
public function testItMapsAValidDetectionResponse(): void
{
$http = new MockHttpClient(
function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies',
$url,
);
self::assertSame(
['url' => 'https://example.com'],
json_decode($options['body'], true),
);
self::assertContains(
'Authorization: Bearer test-token',
$options['headers'],
);
return new MockResponse(json_encode([
'detections' => [[
'name' => 'Example CMS',
'confidence' => 90,
'versions' => ['1.2'],
'evidence' => ['header' => 'example'],
]],
'redirects' => [],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
},
);
$client = new TechnologyDetectorClient($http, 'test-token', new NullLogger());
$report = $client->detect('https://example.com');
self::assertCount(1, $report->detections);
self::assertSame('Example CMS', $report->detections[0]->name);
self::assertSame(['1.2'], $report->detections[0]->versions);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$requests = 0;
$http = new MockHttpClient(function () use (&$requests): MockResponse {
$requests++;
return new MockResponse('{"message":"unauthorized"}', ['http_code' => 401]);
});
$client = new TechnologyDetectorClient($http, 'bad-token', new NullLogger());
try {
$client->detect('https://example.com');
self::fail('Expected DetectorException.');
} catch (DetectorException $exception) {
self::assertSame('authentication_failed', $exception->reason);
self::assertSame(1, $requests);
}
}
}
php bin/phpunit
Security, operations, and deployment
If this command later becomes a web endpoint, do not expose it anonymously. An unrestricted detector proxy can consume plan quota and turn arbitrary user input into outbound work. Require authorization, apply per-user rate limits, validate public URLs, and consider restricting requests to domains attached to active quote records.
Production logs should support alerts on authentication_failed, sustained rate_limited outcomes, transport failures, and sudden drops to zero detections. Never attach authorization headers or complete raw payloads to those logs. If evidence must be retained with a quote, define an explicit retention policy and restrict access to the relevant team.
Set WEBSITE_TECHNOLOGY_DETECTOR_TOKEN in the deployment platform’s secret manager, then deploy normally:
composer install --no-dev --optimize-autoloader
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
APP_ENV=prod php bin/console app:inspect-client-stack https://example.com
Common failures are usually straightforward. HTTP 401 or 403 suggests a missing, revoked, or incorrectly scoped token. HTTP 429 means quota or rate limiting was reached; after bounded retries, defer the inspection rather than looping indefinitely. HTTP 400-class validation errors point to the supplied URL and must not be retried. Repeated 502, 503, 504, or transport failures warrant an operational alert and a later manual rerun. A successful response with unknown fields should remain usable because the mapper ignores unrecognized data, while a zero-result report should be reviewed rather than treated as proof that the site has no technology stack.
Final verification checklist
- The account and Free, Plus, or Pro plan are active.
- The service-scoped token is stored in environment configuration and absent from version control.
- The minimal curl request succeeds against the exact POST endpoint.
- The Symfony command accepts only public HTTP or HTTPS URLs.
- Successful responses map detections, confidence, versions, evidence, and redirect information without unsafe assumptions.
- Authentication and validation failures are not retried.
- Rate limits, selected temporary server failures, and transport failures receive bounded retries.
- Logs contain operational context but no tokens or complete response payloads.
- The test suite passes through
MockHttpClientwithout using live quota. - A real inspection is reviewed by a person before it influences the redesign quote.
The lasting value is not a longer list of detected tools. It is a better boundary between evidence and estimation. Once stack discovery is repeatable, defensive, and reviewable, redesign quotes stop depending on superficial impressions and start reflecting the migration work the team may actually have to deliver.