Symfony: Extract Brand Kits to Safely Draft Landing Pages from Client Websites
A customer pastes their website into your onboarding form and expects the next screen to feel familiar. The tempting implementation is to scrape a logo, copy a few colors, and inject the result into a template. That is also how untrusted URLs, malformed CSS, and brittle integrations reach production.
A safer design treats extracted branding as evidence, not executable presentation. The Brand Kit Extractor API gathers the public site’s visual identity, while the Symfony application validates the response, stores an inert draft, and requires approval before publication.
This tutorial builds that workflow with PHP 8.3, Symfony, HttpClient, Doctrine, and deterministic tests. Extraction remains synchronous to keep the example focused. Its timeouts and retry budget are deliberately small; a queue is the better extension if onboarding must remain responsive during upstream delays.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Brand Kit Extractor 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 its service-scoped token.
- Store that token in environment-backed configuration. Regenerating it revokes the previously active token, so token rotation must include updating every deployed instance that uses the service.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. Bearer authentication is preferable because query strings commonly appear in access logs and monitoring systems.
The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains one field, url. Confirm access with a minimal request:
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://example.com"}'
Put the real credential in .env.local for local development. Do not commit that file. In production, inject the same variable through the deployment platform’s secret store.
# .env
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit
# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Create the Symfony project boundary
The application needs PHP 8.3 or newer, Composer, a supported Symfony application, and a configured Doctrine database. Install only the components used here:
composer require symfony/http-client symfony/orm-pack doctrine/doctrine-migrations-bundle
composer require --dev symfony/test-pack
The relevant project structure is intentionally modest:
src/
Controller/OnboardingBrandDraftController.php
Entity/LandingThemeDraft.php
BrandKit/BrandKit.php
BrandKit/BrandKitException.php
BrandKit/BrandKitExtractor.php
BrandKit/BrandKitMapper.php
tests/
BrandKit/BrandKitExtractorTest.php
Symfony injects the endpoint and token without making either value part of application source:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$brandKitEndpoint: '%env(BRAND_KIT_ENDPOINT)%'
$brandKitToken: '%env(BRAND_KIT_TOKEN)%'
Map the response into a defensive domain object
The service returns brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Remote JSON must still be treated as untrusted input. Validate every required section before persistence, cap its size and depth, and refuse CSS tokens that could terminate a declaration or load an external resource.
The following DTO and mapper use canonical internal field names. If the official documentation changes its response shape, change this single mapper rather than leaking transport details throughout the application.
<?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 static function fromApi(array $payload): BrandKit
{
$name = $payload['brand_name'] ?? null;
if (!is_string($name) || trim($name) === '' || strlen($name) > 200) {
throw new BrandKitException('invalid_response', 'Invalid brand name.');
}
foreach ([
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
] as $field) {
if (!isset($payload[$field]) || !is_array($payload[$field])) {
throw new BrandKitException(
'invalid_response',
sprintf('Missing or invalid response field: %s', $field)
);
}
}
$counter = 0;
foreach (['logos', 'colors', 'fonts', 'imagery', 'social_profiles'] as $field) {
self::validateTree($payload[$field], 0, $counter);
}
$css = [];
foreach ($payload['css_variables'] as $property => $value) {
if (
!is_string($property)
|| !preg_match('/^--[a-z0-9-]{1,80}$/i', $property)
|| !is_string($value)
|| strlen($value) > 160
|| preg_match('/[;{}]|url\s*\(|expression\s*\(/i', $value)
) {
throw new BrandKitException(
'invalid_response',
'Unsafe CSS variable returned by upstream.'
);
}
$css[$property] = $value;
}
return new BrandKit(
trim($name),
$payload['logos'],
$payload['colors'],
$payload['fonts'],
$payload['imagery'],
$payload['social_profiles'],
$css,
);
}
private static function validateTree(mixed $value, int $depth, int &$counter): void
{
if ($depth > 4 || ++$counter > 500) {
throw new BrandKitException('invalid_response', 'Response is too large.');
}
if (is_array($value)) {
foreach ($value as $child) {
self::validateTree($child, $depth + 1, $counter);
}
return;
}
if (
!(is_string($value) || is_int($value) || is_bool($value)
|| $value === null || (is_float($value) && is_finite($value)))
|| (is_string($value) && strlen($value) > 2048)
) {
throw new BrandKitException('invalid_response', 'Invalid response value.');
}
}
}
This boundary validates structure without pretending that remote logos or imagery are safe to embed. Their URLs remain candidate metadata. A later approval process can download approved assets through a controlled media pipeline.
Call the extractor with bounded retries
The client retries transient transport failures, rate limits, and selected gateway failures. It does not retry authentication, validation, or other permanent client errors. Backoff is capped because this implementation runs inside an HTTP request.
<?php
// src/BrandKit/BrandKitException.php
namespace App\BrandKit;
final class BrandKitException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
// src/BrandKit/BrandKitExtractor.php
namespace App\BrandKit;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class BrandKitExtractor
{
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $brandKitToken,
private string $brandKitEndpoint,
private int $maxRetries = 2,
) {}
public function extract(string $url): BrandKit
{
for ($attempt = 0; $attempt <= $this->maxRetries; ++$attempt) {
try {
$response = $this->http->request('POST', $this->brandKitEndpoint, [
'headers' => [
'Authorization' => 'Bearer '.$this->brandKitToken,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 12.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
if ($attempt === $this->maxRetries) {
throw new BrandKitException('transport', 'Extractor unavailable.', $e);
}
$this->backoff($attempt, null);
continue;
}
if (in_array($status, [429, 502, 503, 504], true)) {
$retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
$response->getContent(false);
$this->logger->warning('Brand extraction deferred by upstream.', [
'status' => $status,
'attempt' => $attempt + 1,
]);
if ($attempt < $this->maxRetries) {
$this->backoff($attempt, $retryAfter);
continue;
}
$kind = $status === 429 ? 'rate_limited' : 'upstream';
throw new BrandKitException($kind, 'Extractor temporarily unavailable.');
}
if ($status === 401 || $status === 403) {
$response->getContent(false);
throw new BrandKitException('authentication', 'Extractor authentication failed.');
}
if ($status < 200 || $status >= 300) {
$response->getContent(false);
throw new BrandKitException('request_rejected', 'Extraction request rejected.');
}
try {
$decoded = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException $e) {
throw new BrandKitException('invalid_response', 'Invalid upstream JSON.', $e);
}
if (!is_array($decoded)) {
throw new BrandKitException('invalid_response', 'Unexpected upstream response.');
}
return BrandKitMapper::fromApi($decoded);
}
throw new BrandKitException('upstream', 'Extractor unavailable.');
}
private function backoff(int $attempt, ?string $retryAfter): void
{
$milliseconds = ctype_digit((string) $retryAfter)
? min(2000, (int) $retryAfter * 1000)
: min(2000, 250 * (2 ** $attempt) + random_int(0, 100));
usleep($milliseconds * 1000);
}
}
Logs contain a status and attempt number, never the token, response body, or customer URL. That keeps operational signals useful without turning logs into a secondary store of customer data.
Persist an inert, reviewable draft
A draft should not become live merely because extraction succeeded. Store normalized data as JSON with an explicit pending_review state.
<?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(type: Types::JSON)]
private array $brandKit;
#[ORM\Column(length: 24)]
private string $status = 'pending_review';
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
public function __construct(string $sourceUrl, BrandKit $brandKit)
{
$this->sourceUrl = $sourceUrl;
$this->brandKit = $brandKit->toArray();
$this->createdAt = new \DateTimeImmutable();
}
public function id(): ?int
{
return $this->id;
}
}
Generate and apply the migration after configuring the database:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
Add the onboarding endpoint
The controller accepts JSON such as {"website_url":"https://customer.example"}. It rejects credentials in URLs, unsupported schemes, localhost, and private or reserved literal IP addresses. Because the third-party service performs the fetch, your application does not open a connection to the customer host; the restriction still enforces the product’s public-website rule.
<?php
// src/Controller/OnboardingBrandDraftController.php
namespace App\Controller;
use App\BrandKit\BrandKitException;
use App\BrandKit\BrandKitExtractor;
use App\Entity\LandingThemeDraft;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class OnboardingBrandDraftController
{
#[Route('/api/onboarding/brand-drafts', methods: ['POST'])]
public function __invoke(
Request $request,
BrandKitExtractor $extractor,
EntityManagerInterface $entityManager,
): JsonResponse {
try {
$input = $request->toArray();
} catch (\JsonException) {
return new JsonResponse(['error' => 'invalid_json'], 400);
}
$url = $input['website_url'] ?? null;
if (!is_string($url) || !$this->isPublicWebsiteUrl($url)) {
return new JsonResponse(['error' => 'invalid_website_url'], 422);
}
try {
$kit = $extractor->extract($url);
} catch (BrandKitException $e) {
$status = $e->kind === 'rate_limited' ? 503 : 502;
return new JsonResponse(
['error' => 'brand_extraction_failed', 'reason' => $e->kind],
$status
);
}
$draft = new LandingThemeDraft($url, $kit);
$entityManager->persist($draft);
$entityManager->flush();
return new JsonResponse([
'draft_id' => $draft->id(),
'status' => 'pending_review',
], 201);
}
private function isPublicWebsiteUrl(string $url): bool
{
if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
return false;
}
$parts = parse_url($url);
if (
!is_array($parts)
|| !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|| !isset($parts['host'])
|| isset($parts['user'])
|| isset($parts['pass'])
|| strtolower($parts['host']) === 'localhost'
) {
return false;
}
if (filter_var($parts['host'], FILTER_VALIDATE_IP) !== false) {
return filter_var(
$parts['host'],
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
return filter_var(
$parts['host'],
FILTER_VALIDATE_DOMAIN,
FILTER_FLAG_HOSTNAME
) !== false;
}
}
Test the boundary without making network calls
MockHttpClient makes success and failure paths deterministic. The authentication test also proves that permanent failures are not retried.
<?php
// tests/BrandKit/BrandKitExtractorTest.php
namespace App\Tests\BrandKit;
use App\BrandKit\BrandKitException;
use App\BrandKit\BrandKitExtractor;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class BrandKitExtractorTest extends TestCase
{
public function testItMapsAValidKit(): void
{
$body = json_encode([
'brand_name' => 'Example',
'logos' => [],
'colors' => ['primary' => '#123456'],
'fonts' => ['Inter'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#123456'],
], JSON_THROW_ON_ERROR);
$http = new MockHttpClient(function ($method, $url, $options) use ($body) {
self::assertSame('POST', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
$url
);
self::assertStringContainsString(
'Authorization: Bearer test-token',
implode("\n", $options['headers'])
);
self::assertStringContainsString('example.com', $options['body']);
return new MockResponse($body, ['http_code' => 200]);
});
$extractor = new BrandKitExtractor(
$http,
new NullLogger(),
'test-token',
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
);
self::assertSame('Example', $extractor->extract('https://example.com')->brandName);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$calls = 0;
$http = new MockHttpClient(function () use (&$calls) {
++$calls;
return new MockResponse('{}', ['http_code' => 401]);
});
$extractor = new BrandKitExtractor(
$http,
new NullLogger(),
'invalid-token',
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
);
try {
$extractor->extract('https://example.com');
self::fail('Expected an authentication failure.');
} catch (BrandKitException $e) {
self::assertSame('authentication', $e->kind);
self::assertSame(1, $calls);
}
}
}
php bin/phpunit
curl --request POST 'https://your-app.example/api/onboarding/brand-drafts' \
--header 'Content-Type: application/json' \
--data '{"website_url":"https://example.com"}'
Production hardening and common failures
Protect the onboarding route with your existing authenticated customer context and rate limiter. Add CSRF protection if a browser form uses cookie authentication. Encrypt database storage where required, apply retention rules, and authorize every later read by tenant or account ownership.
Never interpolate stored CSS into a page with string concatenation. Generate declarations only from the validated property/value map, HTML-escape rendered text, and keep the preview behind a restrictive Content Security Policy. Do not load remote logos, imagery, fonts, or social URLs automatically. Present them for review, then proxy or import approved assets through a separately validated pipeline.
Watch structured counts for successful extraction, rate_limited, transport, authentication, request_rejected, and invalid_response. Alert on sustained failure ratios rather than individual customer-site failures. A burst of authentication errors after deployment usually means the environment contains an old token; remember that regeneration revoked it.
A 422 from your controller means the submitted website URL failed local validation. An upstream 401 or 403 points to token configuration or plan activation and must not be retried. A 429 represents quota or rate pressure; return a temporary failure and let the user retry later. Malformed JSON or a changed response shape should fail closed before Doctrine writes anything.
For higher traffic, move extract() into a Symfony Messenger handler. Persist a small request record first, dispatch its identifier, and make the handler idempotent with a unique request key. Do not simply increase HTTP timeouts: that consumes workers while providing a worse onboarding experience.
Final verification checklist
- The activated plan and service-scoped token belong to Brand Kit Extractor.
- The request uses the exact POST endpoint and sends only the intended
urlfield. - The real token exists only in environment-backed secret configuration.
- Connection duration, total duration, retry count, and backoff are bounded.
- Authentication and validation failures are never retried.
- All seven brand-data sections are validated before storage.
- The stored theme remains
pending_reviewand cannot publish itself. - Remote assets and CSS are not rendered as trusted content.
- Tests pass with
MockHttpClient, and migrations succeed in the deployment environment. - Logs and metrics expose failure categories without exposing tokens, response bodies, or customer URLs.
The durable lesson is that brand extraction should shorten design work without bypassing editorial control. Once remote evidence crosses a strict Symfony boundary, the application can offer customers a familiar starting point while keeping the final landing page deliberate, reviewable, and safe.