Tutorials

Symfony Onboarding: Auto-Draft Landing Page Themes with Brand Kit Extractor API

Symfony Onboarding: Auto-Draft Landing Page Themes with Brand Kit Extractor API

A blank onboarding canvas creates an awkward choice: ask a customer to configure colors, fonts, and imagery by hand, or guess what their landing page should look like. A better starting point is the public website they already maintain.

In this tutorial, we will build a Symfony onboarding endpoint that submits the customer’s website to the Brand Kit Extractor API, validates the resulting brand evidence, derives a deliberately conservative theme, and stores it as a draft. Nothing is published automatically. The customer still reviews and approves the result.

That distinction matters. Extracted brand data is useful input, but it remains untrusted external data. URLs can be unsafe, CSS values can become injection vectors, upstream responses can change, and a visually correct color may still fail accessibility requirements. Our integration will preserve the evidence while exposing only a narrow, validated theme to the landing-page renderer.

Get access and copy the service token

Before writing integration code, create or access your account:

  1. Register at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
  2. Open the Brand Kit Extractor service page.
  3. Choose an available Free, Plus, or Pro plan and complete activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy the service-scoped token.

Regenerating this token revokes the previous active token. Treat rotation as a deployment change: update the application secret, deploy or restart affected processes, verify one request, and only then consider the rotation complete.

The API accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because it stays out of URLs and routine access logs. This service is not tokenless: every request needs one of the supported authentication forms.

Confirm the exact API contract

The integration makes this request:

POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit

The JSON body contains one field, url. Test the credential before involving Symfony:

curl --fail-with-body \
  --request POST \
  --url 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://www.example.com"}'

Do not paste a real token into shell history on a shared machine. For local development, put it in Symfony’s ignored .env.local file. In production, inject the same environment variable through the hosting platform or secret manager.

# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Choose a small, review-first architecture

The feature has four boundaries: an authenticated onboarding controller, an HTTP client, a domain mapper, and a database table. The mapper accepts the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables only after validating their types and values.

This example performs extraction synchronously. That keeps a modest onboarding flow easy to operate and lets the customer receive an immediate draft. If measured response times exceed your web request budget, move the same client call behind Symfony Messenger and make the controller return an accepted job identifier. Do not add a queue merely to disguise missing timeouts.

The relevant files are:

src/
  Brand/BrandKitClient.php
  Brand/BrandKitDraft.php
  Brand/BrandKitException.php
  Controller/OnboardingThemeController.php
tests/
  Brand/BrandKitClientTest.php
migrations/
  VersionCreateOnboardingThemeDraft.php
config/
  services.yaml

Start from a Symfony application running PHP 8.3 or later with PostgreSQL configured, then install the first-party HTTP client, CSRF protection, Doctrine integration, migrations, and the test tools:

composer require symfony/http-client symfony/security-csrf \
  doctrine/doctrine-bundle doctrine/doctrine-migrations-bundle
composer require --dev symfony/test-pack

Bind the environment-backed token and fixed endpoint through dependency injection:

# config/services.yaml
parameters:
  brand_kit.endpoint: 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'

services:
  App\Brand\BrandKitClient:
    arguments:
      $token: '%env(string:BRAND_KIT_TOKEN)%'
      $endpoint: '%brand_kit.endpoint%'

Map the response into a safe domain draft

The API provides evidence-based brand data, but the renderer should never concatenate returned CSS into a stylesheet. The following mapper requires all seven categories, limits collection sizes, validates URLs and colors, and stores CSS variables only as inspected evidence. The actual theme is rebuilt from safe primitives.

<?php
// src/Brand/BrandKitDraft.php

namespace App\Brand;

final readonly class BrandKitDraft implements \JsonSerializable
{
    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 static function fromApi(array $data): self
    {
        $brandName = $data['brand_name'] ?? null;

        if (!is_string($brandName) || trim($brandName) === '' || strlen($brandName) > 200) {
            throw new \DomainException('Invalid brand name.');
        }

        return new self(
            trim($brandName),
            self::stringList($data, 'logos', self::validUrl(...)),
            self::stringList($data, 'colors', self::validColor(...)),
            self::stringList($data, 'fonts', self::validFont(...)),
            self::stringList($data, 'imagery', self::validUrl(...)),
            self::stringList($data, 'social_profiles', self::validUrl(...)),
            self::cssMap($data),
        );
    }

    public function theme(): array
    {
        return [
            'primary_color' => $this->colors[0] ?? '#1f2937',
            'secondary_color' => $this->colors[1] ?? '#f3f4f6',
            'font_family' => $this->fonts[0] ?? 'system-ui',
            'logo_candidate' => $this->logos[0] ?? null,
            'review_required' => true,
        ];
    }

    public function jsonSerialize(): 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,
        ];
    }

    private static function stringList(
        array $data,
        string $key,
        callable $validator,
    ): array {
        $values = $data[$key] ?? null;

        if (!is_array($values) || !array_is_list($values) || count($values) > 50) {
            throw new \DomainException(sprintf('Invalid %s collection.', $key));
        }

        foreach ($values as $value) {
            if (!is_string($value) || strlen($value) > 2048 || !$validator($value)) {
                throw new \DomainException(sprintf('Invalid value in %s.', $key));
            }
        }

        return array_values(array_unique($values));
    }

    private static function cssMap(array $data): array
    {
        $variables = $data['css_variables'] ?? null;

        if (!is_array($variables) || count($variables) > 100) {
            throw new \DomainException('Invalid CSS variables.');
        }

        foreach ($variables as $name => $value) {
            if (
                !is_string($name)
                || preg_match('/^--[a-z0-9-]{1,80}$/', $name) !== 1
                || !is_string($value)
                || strlen($value) > 200
            ) {
                throw new \DomainException('Invalid CSS variable.');
            }
        }

        return $variables;
    }

    private static function validUrl(string $value): bool
    {
        if (filter_var($value, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        return in_array(strtolower((string) parse_url($value, PHP_URL_SCHEME)), ['http', 'https'], true);
    }

    private static function validColor(string $value): bool
    {
        return preg_match('/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}|[0-9a-fA-F]{5})?$/', $value) === 1;
    }

    private static function validFont(string $value): bool
    {
        return preg_match("/^[\p{L}\p{N} .,'-]{1,80}$/u", $value) === 1;
    }
}

This intentionally strict mapper reflects the application’s accepted boundary. If the official documentation defines nested objects rather than string collections, map those documented objects explicitly; do not loosen validation to accept arbitrary response trees.

Build a bounded, status-aware HTTP client

The client uses an inactivity timeout, an overall duration limit, and at most three attempts. Authentication and validation failures are never retried. Transport failures, rate limits, and server failures receive bounded backoff.

<?php
// src/Brand/BrandKitException.php
namespace App\Brand;

final class BrandKitException extends \RuntimeException
{
    public function __construct(public readonly string $kind)
    {
        parent::__construct($kind);
    }
}
<?php
// src/Brand/BrandKitClient.php

namespace App\Brand;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class BrandKitClient
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
        private string $endpoint,
    ) {}

    public function extract(string $url): BrandKitDraft
    {
        if (trim($this->token) === '') {
            throw new BrandKitException('configuration');
        }

        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' => 12.0,
                ]);

                $status = $response->getStatusCode();

                $this->logger->info('brand_kit.response', [
                    'attempt' => $attempt,
                    'status' => $status,
                    'host_hash' => hash('sha256', (string) parse_url($url, PHP_URL_HOST)),
                ]);

                if ($status >= 200 && $status < 300) {
                    try {
                        $data = json_decode(
                            $response->getContent(false),
                            true,
                            512,
                            JSON_THROW_ON_ERROR,
                        );
                    } catch (\JsonException) {
                        throw new BrandKitException('invalid_response');
                    }

                    if (!is_array($data)) {
                        throw new BrandKitException('invalid_response');
                    }

                    return BrandKitDraft::fromApi($data);
                }

                if ($status === 401 || $status === 403) {
                    throw new BrandKitException('authentication');
                }

                if ($status === 400 || $status === 422) {
                    throw new BrandKitException('request_rejected');
                }

                if ($status === 429) {
                    if ($attempt === 3) {
                        throw new BrandKitException('rate_limited');
                    }

                    $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
                    $seconds = ctype_digit((string) $retryAfter)
                        ? min(5, (int) $retryAfter)
                        : $attempt;

                    usleep($seconds * 1_000_000);
                    continue;
                }

                if ($status >= 500 && $attempt < 3) {
                    usleep($attempt * 300_000);
                    continue;
                }

                throw new BrandKitException('upstream_failure');
            } catch (TransportExceptionInterface) {
                if ($attempt === 3) {
                    throw new BrandKitException('transport');
                }

                usleep($attempt * 300_000);
            }
        }

        throw new BrandKitException('upstream_failure');
    }
}

The logger records no token, response body, or full customer URL. The structured kind values distinguish operator action: rotate credentials for authentication failures, inspect payload compatibility for invalid responses, and examine quota or capacity for persistent rate limits.

Validate onboarding input and store only drafts

Create a PostgreSQL migration containing this table, then run php bin/console doctrine:migrations:migrate --no-interaction during deployment:

CREATE TABLE onboarding_theme_draft (
    id CHAR(32) PRIMARY KEY,
    owner_identifier VARCHAR(180) NOT NULL,
    source_host VARCHAR(253) NOT NULL,
    brand_name VARCHAR(200) NOT NULL,
    brand_kit JSONB NOT NULL,
    theme JSONB NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_theme_draft_owner
    ON onboarding_theme_draft (owner_identifier, created_at);

The controller requires an authenticated user and a valid CSRF token. It accepts HTTPS public hostnames only. That reduces accidental misuse and quota abuse, although the remote extraction service must still enforce its own DNS and outbound-network protections.

<?php
// src/Controller/OnboardingThemeController.php

namespace App\Controller;

use App\Brand\BrandKitClient;
use App\Brand\BrandKitException;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

final class OnboardingThemeController extends AbstractController
{
    #[Route('/onboarding/theme-draft', name: 'onboarding_theme_draft', methods: ['POST'])]
    #[IsGranted('ROLE_USER')]
    public function __invoke(
        Request $request,
        CsrfTokenManagerInterface $csrf,
        BrandKitClient $client,
        Connection $database,
    ): JsonResponse {
        $token = new CsrfToken(
            'onboarding_theme',
            (string) $request->headers->get('X-CSRF-Token'),
        );

        if (!$csrf->isTokenValid($token)) {
            return $this->json(['error' => 'invalid_csrf_token'], 403);
        }

        try {
            $input = $request->toArray();
        } catch (\JsonException) {
            return $this->json(['error' => 'invalid_json'], 400);
        }

        $url = $input['url'] ?? null;
        $host = is_string($url) ? parse_url($url, PHP_URL_HOST) : null;

        if (
            !is_string($url)
            || filter_var($url, FILTER_VALIDATE_URL) === false
            || strtolower((string) parse_url($url, PHP_URL_SCHEME)) !== 'https'
            || !is_string($host)
            || !str_contains($host, '.')
            || filter_var($host, FILTER_VALIDATE_IP) !== false
            || strtolower($host) === 'localhost'
        ) {
            return $this->json(['error' => 'invalid_public_url'], 422);
        }

        try {
            $draft = $client->extract($url);
        } catch (BrandKitException|\DomainException $exception) {
            $kind = $exception instanceof BrandKitException
                ? $exception->kind
                : 'invalid_response';

            return $this->json(
                ['error' => 'theme_draft_unavailable', 'reason' => $kind],
                503,
                $kind === 'rate_limited' ? ['Retry-After' => '10'] : [],
            );
        }

        $id = bin2hex(random_bytes(16));
        $user = $this->getUser();

        $database->insert('onboarding_theme_draft', [
            'id' => $id,
            'owner_identifier' => $user->getUserIdentifier(),
            'source_host' => strtolower($host),
            'brand_name' => $draft->brandName,
            'brand_kit' => json_encode($draft, JSON_THROW_ON_ERROR),
            'theme' => json_encode($draft->theme(), JSON_THROW_ON_ERROR),
            'status' => 'review_required',
            'created_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
        ]);

        return $this->json([
            'id' => $id,
            'status' => 'review_required',
            'theme' => $draft->theme(),
        ], 201);
    }
}

Generate the browser request’s header value with Twig’s csrf_token('onboarding_theme'). If your user identifier is an email address, replace it with an immutable user ID and a foreign key before launch.

Test success and non-retryable failure paths

MockHttpClient keeps tests deterministic and proves that the application never needs the live service during the test suite.

<?php
// tests/Brand/BrandKitClientTest.php

namespace App\Tests\Brand;

use App\Brand\BrandKitClient;
use App\Brand\BrandKitException;
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 testMapsAValidResponseIntoAReviewDraft(): void
    {
        $http = new MockHttpClient(function (string $method, string $url): MockResponse {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
                $url,
            );

            return new MockResponse(json_encode([
                'brand_name' => 'Example Studio',
                'logos' => ['https://www.example.com/logo.svg'],
                'colors' => ['#123456', '#f4f4f4'],
                'fonts' => ['Inter'],
                'imagery' => ['https://www.example.com/hero.jpg'],
                'social_profiles' => ['https://www.example.com/social'],
                'css_variables' => ['--brand-primary' => '#123456'],
            ], JSON_THROW_ON_ERROR));
        });

        $client = new BrandKitClient(
            $http,
            new NullLogger(),
            'test-token',
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        );

        $draft = $client->extract('https://www.example.com');

        self::assertSame('Example Studio', $draft->brandName);
        self::assertTrue($draft->theme()['review_required']);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $calls = 0;
        $http = new MockHttpClient(function () use (&$calls): MockResponse {
            $calls++;
            return new MockResponse('{}', ['http_code' => 401]);
        });

        $client = new BrandKitClient(
            $http,
            new NullLogger(),
            'expired-token',
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        );

        try {
            $client->extract('https://www.example.com');
            self::fail('Expected authentication failure.');
        } catch (BrandKitException $exception) {
            self::assertSame('authentication', $exception->kind);
        }

        self::assertSame(1, $calls);
    }
}

Run php bin/phpunit. Add mapper tests for an unsafe logo scheme, malformed color, oversized collection, invalid CSS variable name, and missing response category. Add a functional controller test to verify authentication, CSRF enforcement, ownership, and that no database row appears after an upstream failure.

Production concerns that deserve explicit decisions

  • Rendering: assign validated values through controlled template properties or the CSS Object Model. Never render returned CSS-variable text as a raw stylesheet.
  • Accessibility: extracted colors are visual evidence, not proof of readable contrast. Run your own contrast checks and provide a neutral fallback.
  • Asset privacy: avoid automatically proxying or downloading logos and imagery. Show candidates during review and define a retention policy for rejected drafts.
  • Quotas: debounce the onboarding action, prevent parallel submissions per user, and cache or reuse a recent draft for the same normalized host when appropriate.
  • Observability: alert on sustained authentication, rate-limit, transport, and invalid-response failures. Track latency and attempt counts without recording credentials or unnecessary customer data.
  • Deployment: inject BRAND_KIT_TOKEN into both web and worker environments if Messenger is later introduced. Warm the Symfony cache and run migrations before sending traffic to the new route.

Common failures and what they mean

A 401 or 403 usually points to a missing, revoked, or incorrectly copied service token. Do not retry it. A 400 or 422 means the request should be corrected rather than repeated. A 429 requires bounded waiting and plan or quota inspection. Repeated 5xx or transport failures should leave onboarding recoverable: retain the customer’s entered URL in the browser and offer a later retry.

An invalid_response failure is especially valuable. It prevents an upstream shape change from silently entering storage or reaching a stylesheet. Compare the response with the official documentation, update the mapper deliberately, and add a fixture covering the revised contract.

Final verification checklist

  • The test request reaches the exact POST endpoint with a JSON url.
  • The real token exists only in environment-backed secret configuration.
  • Authentication, CSRF, URL, response, and ownership checks are active.
  • Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
  • Authentication and request-validation failures are not retried.
  • Timeouts, retry counts, backoff, and rate-limit waits are bounded.
  • Logs exclude tokens, response bodies, and full customer URLs.
  • The resulting record has review_required status and cannot publish itself.
  • A human can approve, edit, or reject the proposed theme.

The best onboarding automation does not pretend extraction is judgment. It turns an existing website into a useful first draft, surrounds uncertain evidence with strict boundaries, and leaves the final decision with the customer. That combination makes the experience feel fast without making the system reckless.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.