Symfony: Изградете безбедни целни страници за воведување со Brand Kit AI
A customer pastes a website URL into onboarding. A few seconds later, your application has a proposed palette, font direction, and supporting brand evidence. That sounds simple until the result is allowed anywhere near a real landing page.
Remote content is untrusted, even when an extraction service has normalized it. Production code must validate the API boundary, constrain retries, keep credentials out of logs, and create a reviewable draft rather than silently publishing third-party CSS or assets.
This tutorial builds that workflow in Symfony on PHP 8.3 or later. It calls the Brand Kit Extractor API, maps its response into a domain object, derives a conservative theme, and persists the result as an unpublished onboarding draft.
Prerequisites and design
You need PHP 8.3+, Composer, a Symfony application, and a database supported by Doctrine. The example uses PostgreSQL in production, although Doctrine keeps the application code database-independent.
The integration remains synchronous because an onboarding user expects an immediate result and the operation has a strict time budget. If extraction volume grows or your web request limit is short, move the same application service behind Symfony Messenger; do not duplicate the API logic inside a message handler.
The trust boundary is deliberate:
- The controller accepts only a plausible public HTTP or HTTPS URL.
- The API client sends that URL with bounded timeouts and narrowly scoped retries.
- A mapper requires every contracted response category before storage.
- The theme factory derives new variables from conservative values instead of executing returned CSS.
- The database records a draft that still requires human review.
Get access before writing integration code
- Register an account, or sign in if you already have one.
- Open the Brand Kit Extractor service page. Choose the available Free, Plus, or Pro plan and complete activation.
- Open the official documentation, find the Service token panel, and copy the service-scoped token.
- Store that token as an environment secret. Regenerating it revokes the previously active token, so token rotation must update deployed environments before dependent instances restart.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. A Bearer header is preferable because credentials do not appear in URLs, proxy histories, or routine access logs.
Confirm the exact request
The operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url. Run one minimal request before involving Symfony:
curl --request POST \
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Content-Type: application/json' \
--data '{"url":"https://www.example.com"}'
Do not paste a real token into shell history on a shared machine. Use this form only with an appropriate local secret-injection practice.
For local development, put the credential in uncommitted .env.local. Set the same names through your production secret manager:
# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
Scaffold the Symfony project
composer create-project symfony/skeleton brand-onboarding
cd brand-onboarding
composer require symfony/framework-bundle symfony/http-client symfony/orm-pack
composer require --dev symfony/maker-bundle symfony/test-pack
The feature will live in four areas: src/BrandKit for the external boundary, src/Onboarding for safe theme derivation, src/Entity for persistence, and src/Controller for the HTTP entry point.
Map the external response into a domain object
The contract requires a brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Validate their presence and top-level types before anything reaches Doctrine. Empty collections are allowed because a public site may genuinely provide no social profile or reusable imagery.
<?php
// src/BrandKit/BrandKit.php
namespace App\BrandKit;
final readonly class BrandKit
{
public function __construct(
public string $brandName,
public array $logos,
public array $colors,
public array $fonts,
public array $imagery,
public array $socialProfiles,
public array $cssVariables,
) {}
public function toArray(): array
{
return [
'brand_name' => $this->brandName,
'logos' => $this->logos,
'colors' => $this->colors,
'fonts' => $this->fonts,
'imagery' => $this->imagery,
'social_profiles' => $this->socialProfiles,
'css_variables' => $this->cssVariables,
];
}
}
// src/BrandKit/BrandKitMapper.php
namespace App\BrandKit;
final class BrandKitMapper
{
public function map(array $data): BrandKit
{
foreach ([
'brand_name', 'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
] as $field) {
if (!array_key_exists($field, $data)) {
throw new \UnexpectedValueException("Missing response field: {$field}");
}
}
if (!is_string($data['brand_name'])
|| trim($data['brand_name']) === ''
|| mb_strlen($data['brand_name']) > 255) {
throw new \UnexpectedValueException('Invalid brand_name');
}
foreach ([
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
] as $field) {
if (!is_array($data[$field])) {
throw new \UnexpectedValueException("Invalid {$field}");
}
}
return new BrandKit(
trim($data['brand_name']),
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$data['css_variables'],
);
}
}
This mapper deliberately makes no undocumented assumptions about inner logo, font, or imagery shapes. Rendering code must not guess that an arbitrary nested string is a safe URL or stylesheet.
Build a bounded, observable API client
The client makes at most three attempts. It retries transport failures, rate limits with a short acceptable Retry-After, and transient gateway responses. Authentication and request-validation failures are never retried. Because retries can consume quota, long rate-limit delays become a structured failure for the caller instead of a sleeping PHP request.
<?php
// src/BrandKit/BrandKitApiException.php
namespace App\BrandKit;
final class BrandKitApiException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
string $message,
) {
parent::__construct($message);
}
}
// src/BrandKit/BrandKitClient.php
namespace App\BrandKit;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class BrandKitClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';
public function __construct(
private readonly HttpClientInterface $http,
#[Autowire('%env(BRAND_KIT_TOKEN)%')]
private readonly string $token,
private readonly LoggerInterface $logger,
private readonly ?\Closure $sleep = null,
private readonly BrandKitMapper $mapper = new BrandKitMapper(),
) {}
public function extract(string $url): BrandKit
{
$host = parse_url($url, PHP_URL_HOST);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', self::ENDPOINT, [
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 6.0,
'max_duration' => 8.0,
]);
$status = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new BrandKitApiException(
'temporary', true, 'Brand extraction is unavailable.'
);
}
$this->logRetry($attempt, null, $host);
$this->pause(250 * (2 ** ($attempt - 1)));
continue;
}
if ($status >= 200 && $status < 300) {
return $this->decode($response);
}
if (in_array($status, [401, 403], true)) {
$response->cancel();
throw new BrandKitApiException(
'authentication', false, 'Service authentication failed.'
);
}
if (in_array($status, [400, 422], true)) {
$response->cancel();
throw new BrandKitApiException(
'input', false, 'The website URL was rejected.'
);
}
if ($status === 429) {
$delay = $this->retryAfterMilliseconds($response);
$response->cancel();
if ($attempt < 3 && $delay <= 2000) {
$this->logRetry($attempt, $status, $host);
$this->pause($delay);
continue;
}
throw new BrandKitApiException(
'quota', true, 'Extraction is rate limited.'
);
}
$response->cancel();
if (in_array($status, [502, 503, 504], true) && $attempt < 3) {
$this->logRetry($attempt, $status, $host);
$this->pause(250 * (2 ** ($attempt - 1)));
continue;
}
throw new BrandKitApiException(
'upstream', false, 'Unexpected extraction response.'
);
}
throw new \LogicException('Unreachable retry state');
}
private function decode(ResponseInterface $response): BrandKit
{
$body = '';
try {
foreach ($this->http->stream($response) as $chunk) {
$body .= $chunk->getContent();
if (strlen($body) > 1_000_000) {
$response->cancel();
throw new BrandKitApiException(
'schema', false, 'Extraction response is too large.'
);
}
}
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new \UnexpectedValueException('Expected a JSON object');
}
return $this->mapper->map($data);
} catch (BrandKitApiException $e) {
throw $e;
} catch (\JsonException|\UnexpectedValueException $e) {
throw new BrandKitApiException(
'schema', false, 'Extraction response has an invalid schema.'
);
}
}
private function retryAfterMilliseconds(ResponseInterface $response): int
{
$value = $response->getHeaders(false)['retry-after'][0] ?? null;
return is_string($value) && ctype_digit($value)
? ((int) $value) * 1000
: 500;
}
private function pause(int $milliseconds): void
{
if ($this->sleep !== null) {
($this->sleep)($milliseconds);
return;
}
usleep($milliseconds * 1000);
}
private function logRetry(int $attempt, ?int $status, mixed $host): void
{
$this->logger->warning('Brand extraction will retry', [
'attempt' => $attempt,
'status' => $status,
'host' => is_string($host) ? $host : null,
]);
}
}
The log contains neither the token nor the complete customer URL. In production, add request correlation IDs and metrics for latency, retry count, status class, schema failures, and successful draft creation.
Derive and store a safe draft
Never insert returned CSS variables, logo markup, font URLs, or imagery directly into a page. The following factory recursively finds only six- or eight-digit hexadecimal colors and conservative font labels. It generates application-owned variable names and leaves all extracted evidence available for review.
<?php
// src/Onboarding/ThemeDraftFactory.php
namespace App\Onboarding;
use App\BrandKit\BrandKit;
final class ThemeDraftFactory
{
public function create(BrandKit $kit): array
{
$colors = array_values(array_unique(array_filter(
$this->strings($kit->colors),
static fn (string $value): bool =>
preg_match('/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/', $value) === 1
)));
$fonts = array_values(array_unique(array_filter(
$this->strings($kit->fonts),
static fn (string $value): bool =>
preg_match('/^[\pL\pN][\pL\pN ._-]{0,79}$/u', $value) === 1
)));
$variables = [];
foreach (array_slice($colors, 0, 6) as $index => $color) {
$variables['--brand-color-'.($index + 1)] = strtolower($color);
}
return [
'css_variables' => $variables,
'font_suggestions' => array_slice($fonts, 0, 3),
'review_required' => true,
];
}
private function strings(array $values): array
{
$result = [];
array_walk_recursive($values, static function (mixed $value) use (&$result): void {
if (is_string($value)) {
$result[] = trim($value);
}
});
return $result;
}
}
Persist the normalized evidence and derived theme as JSON. The status begins as draft; publication must be a separate, authorized action.
<?php
// src/Entity/LandingThemeDraft.php
namespace App\Entity;
use App\BrandKit\BrandKit;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class LandingThemeDraft
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 2048)]
private string $sourceUrl;
#[ORM\Column(length: 255)]
private string $brandName;
#[ORM\Column(type: Types::JSON)]
private array $brandKit;
#[ORM\Column(type: Types::JSON)]
private array $theme;
#[ORM\Column(length: 20)]
private string $status = 'draft';
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(string $url, BrandKit $kit, array $theme)
{
$this->sourceUrl = $url;
$this->brandName = $kit->brandName;
$this->brandKit = $kit->toArray();
$this->theme = $theme;
$this->createdAt = new \DateTimeImmutable();
}
public function id(): ?int
{
return $this->id;
}
}
Expose the onboarding route
The controller rejects malformed, local, IP-literal, credential-bearing, and non-HTTP URLs. It does not fetch the website itself. The extraction service must independently enforce safe outbound-fetch behavior, including DNS resolution and redirect checks.
<?php
// src/Controller/CreateThemeDraftController.php
namespace App\Controller;
use App\BrandKit\BrandKitApiException;
use App\BrandKit\BrandKitClient;
use App\Entity\LandingThemeDraft;
use App\Onboarding\ThemeDraftFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class CreateThemeDraftController
{
#[Route('/onboarding/theme-drafts', methods: ['POST'])]
public function __invoke(
Request $request,
BrandKitClient $client,
ThemeDraftFactory $themes,
EntityManagerInterface $entityManager,
): JsonResponse {
try {
$input = json_decode(
$request->getContent(), true, 512, JSON_THROW_ON_ERROR
);
$url = is_array($input) ? ($input['url'] ?? null) : null;
$this->assertPublicWebsiteUrl($url);
$kit = $client->extract($url);
$draft = new LandingThemeDraft($url, $kit, $themes->create($kit));
$entityManager->persist($draft);
$entityManager->flush();
return new JsonResponse([
'id' => $draft->id(),
'status' => 'draft',
'review_required' => true,
], 201);
} catch (\JsonException|\InvalidArgumentException $e) {
return new JsonResponse([
'error' => ['code' => 'invalid_input'],
], 422);
} catch (BrandKitApiException $e) {
$status = match ($e->kind) {
'input' => 422,
'authentication' => 500,
'quota', 'temporary' => 503,
default => 502,
};
return new JsonResponse([
'error' => [
'code' => 'brand_extraction_'.$e->kind,
'retryable' => $e->retryable,
],
], $status);
}
}
private function assertPublicWebsiteUrl(mixed $url): void
{
if (!is_string($url) || strlen($url) > 2048
|| filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new \InvalidArgumentException();
}
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true)
|| isset($parts['user']) || isset($parts['pass'])
|| filter_var($host, FILTER_VALIDATE_IP) !== false
|| filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false
|| $host === 'localhost' || str_ends_with($host, '.local')) {
throw new \InvalidArgumentException();
}
}
}
Protect this route with your normal authenticated onboarding firewall. For cookie-authenticated browser requests, retain CSRF protection; for token-authenticated APIs, configure restrictive CORS and authorization. Add a per-account rate limiter so one customer cannot spend the entire extraction quota.
Create the schema and test failure paths
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
php bin/phpunit
Use MockHttpClient so tests never consume quota or depend on the network. This test proves that rate limiting retries deterministically and that the complete response maps successfully.
<?php
// tests/BrandKit/BrandKitClientTest.php
namespace App\Tests\BrandKit;
use App\BrandKit\BrandKitClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class BrandKitClientTest extends TestCase
{
public function testRetriesRateLimitAndMapsResponse(): void
{
$payload = [
'brand_name' => 'Example',
'logos' => [],
'colors' => ['primary' => '#123456'],
'fonts' => ['Inter'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--source-color' => '#123456'],
];
$http = new MockHttpClient([
new MockResponse('{}', [
'http_code' => 429,
'response_headers' => ['retry-after: 0'],
]),
new MockResponse(json_encode($payload, JSON_THROW_ON_ERROR), [
'http_code' => 200,
'response_headers' => ['content-type: application/json'],
]),
]);
$delays = [];
$client = new BrandKitClient(
$http,
'test-token',
new NullLogger(),
static function (int $milliseconds) use (&$delays): void {
$delays[] = $milliseconds;
},
);
$kit = $client->extract('https://www.example.com');
self::assertSame('Example', $kit->brandName);
self::assertSame([0], $delays);
self::assertSame(2, $http->getRequestsCount());
}
}
Add companion cases for missing fields, non-JSON responses, oversized bodies, transport failure, and 401 responses. The authentication test should assert one request only, proving that invalid credentials are not blindly retried.
Deployment, failures, and final verification
Inject BRAND_KIT_TOKEN and DATABASE_URL at runtime, run migrations before routing traffic to the new version, and warm the Symfony production cache. Permit outbound HTTPS to the documented API host and ensure your ingress timeout exceeds the client’s bounded retry window. Multiple application replicas require no shared retry state because each request persists only after successful validation.
Common failures are usually legible: 401 or 403 means the service token is missing, revoked, or scoped incorrectly; 429 means the plan quota or rate limit needs attention; 400 or 422 means the submitted website was rejected; repeated 502, 503, 504, or transport errors indicate a temporary upstream or network problem; and a schema failure means storage was correctly prevented when the response no longer matched the expected contract.
Before release, verify the complete path:
- A real public website produces a persisted record with
statusset todraft. - All seven brand categories are validated before the transaction is written.
- Tokens, response bodies, and full customer URLs are absent from logs and fixtures.
- Authentication and validation failures make one attempt; transient failures remain bounded.
- Returned CSS and remote assets are never executed or published automatically.
- An authorized reviewer must approve the theme before a landing page can use it.
The valuable output is not merely a palette. It is a controlled handoff from public evidence to an editable proposal. By treating extraction as an untrusted boundary and publication as a separate decision, onboarding becomes fast without turning customer convenience into a security shortcut.