Tutorials

Symfony: Auto-Brand New Client Workspaces with AI Brand Kit Extraction

Symfony: Auto-Brand New Client Workspaces with AI Brand Kit Extraction

A new client workspace often begins with a surprisingly unproductive scavenger hunt: find the official logo, copy the right colors, identify the typefaces, and decide which assets are trustworthy. Automating that work makes onboarding faster, but only if the result is validated, retryable, observable, and safe to store.

This tutorial builds a Symfony application that creates a workspace immediately, then dispatches a background job to extract the client’s public visual identity. The Brand Kit Extractor API supplies evidence-based brand data; Symfony Messenger keeps the HTTP request responsive; and a strict domain mapper prevents malformed upstream data from entering the database.

Get access to the Brand Kit Extractor

This service requires a service-scoped token. Complete the access flow before writing integration code:

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

Regenerating this token revokes the previously active token, so treat rotation as a deployment change: update every environment using the old value before restarting its workers.

Confirm the endpoint with a minimal request

The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. It accepts a JSON object containing url.

The API supports a Bearer token, an X-API-Token header, or a token query parameter. This project uses Bearer authentication because query-string credentials can leak into access logs, analytics, and browser history.

curl --request POST \
  --url 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"}'

Inspect this smoke-test response, but do not paste it blindly into a fixture. The application will independently validate the brand name, logos, colors, fonts, imagery, social profiles, and CSS variables.

Put the credential in environment-backed configuration

For local development, place the token in .env.local, which should remain uncommitted. Set it through the deployment platform’s secret manager in production.

# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

# .env
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit

Bind those values by argument name in config/services.yaml:

services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      $brandKitToken: '%env(BRAND_KIT_TOKEN)%'
      $brandKitEndpoint: '%env(BRAND_KIT_ENDPOINT)%'

Architecture: respond now, enrich afterward

Brand extraction involves a remote site and a remote API. Making workspace creation wait for both would couple user-facing latency to systems outside your control. The controller therefore persists a workspace with a pending state and dispatches a Messenger message. A worker calls the API and changes that state to ready or failed.

The important project files are:

  • src/Brand/BrandKit.php: validated domain representation.
  • src/Infrastructure/BrandKitExtractor.php: HTTP boundary, retries, and failure classification.
  • src/Entity/Workspace.php: stored workspace and extraction state.
  • src/Message/PrefillWorkspaceBrand.php: asynchronous work request.
  • src/MessageHandler/PrefillWorkspaceBrandHandler.php: orchestration.
  • src/Controller/WorkspaceController.php: workspace creation endpoint.

Install the required Symfony and Doctrine components in an existing Symfony PHP 8.3 application:

composer require symfony/http-client symfony/messenger symfony/doctrine-messenger
composer require doctrine/orm doctrine/doctrine-bundle
composer require --dev symfony/test-pack

Validate the response at the application boundary

Remote JSON is input, not a domain object. The mapper below requires every contracted top-level field and recursively rejects values that cannot be stored as JSON. It deliberately avoids inventing undocumented nested logo, color, or font fields.

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

final readonly class BrandKit
{
    private 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 fromPayload(array $payload): self
    {
        $name = $payload['brand_name'] ?? null;

        if (!is_string($name) || trim($name) === '') {
            throw new \UnexpectedValueException('Invalid brand_name');
        }

        $fields = [];
        foreach ([
            'logos',
            'colors',
            'fonts',
            'imagery',
            'social_profiles',
            'css_variables',
        ] as $key) {
            if (!array_key_exists($key, $payload) || !is_array($payload[$key])) {
                throw new \UnexpectedValueException("Invalid {$key}");
            }

            self::assertJsonTree($payload[$key], $key);
            $fields[$key] = $payload[$key];
        }

        return new self(
            trim($name),
            $fields['logos'],
            $fields['colors'],
            $fields['fonts'],
            $fields['imagery'],
            $fields['social_profiles'],
            $fields['css_variables'],
        );
    }

    private static function assertJsonTree(array $value, string $path): void
    {
        foreach ($value as $key => $item) {
            $child = $path.'.'.$key;

            if (is_array($item)) {
                self::assertJsonTree($item, $child);
            } elseif (
                !is_string($item) &&
                !is_int($item) &&
                !is_float($item) &&
                !is_bool($item) &&
                $item !== null
            ) {
                throw new \UnexpectedValueException("Invalid value at {$child}");
            }
        }
    }
}

If the official documentation changes the response envelope, adapt this one mapper rather than spreading response-shape assumptions across controllers, entities, and templates.

Build a bounded, retry-aware API client

The client uses explicit connection and total-duration limits. It retries transport failures, rate limiting, and selected upstream failures at most twice after the initial request. Authentication, malformed requests, and invalid responses are not retried.

<?php
// src/Infrastructure/BrandKitExtractor.php
namespace App\Infrastructure;

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

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($kind, 0, $previous);
    }
}

final class BrandKitExtractor
{
    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        private readonly string $brandKitToken,
        private readonly string $brandKitEndpoint,
        ?\Closure $sleep = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $microseconds) =>
            usleep($microseconds);
    }

    public function extract(string $url): BrandKit
    {
        if ($this->brandKitToken === '') {
            throw new ApiFailure('configuration');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', $this->brandKitEndpoint, [
                    'auth_bearer' => $this->brandKitToken,
                    'json' => ['url' => $url],
                    'timeout' => 10.0,
                    'max_duration' => 30.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);

                if (
                    ($status === 429 || in_array($status, [502, 503, 504], true))
                    && $attempt < 3
                ) {
                    $this->pause($headers, $attempt);
                    continue;
                }

                if ($status < 200 || $status >= 300) {
                    $kind = match (true) {
                        $status === 401 || $status === 403 => 'authentication',
                        $status === 429 => 'quota_or_rate_limit',
                        $status >= 400 && $status < 500 => 'request_rejected',
                        default => 'upstream',
                    };

                    throw new ApiFailure($kind, $status);
                }

                try {
                    $payload = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );

                    if (!is_array($payload)) {
                        throw new \UnexpectedValueException('Expected an object');
                    }

                    return BrandKit::fromPayload($payload);
                } catch (\JsonException|\UnexpectedValueException $error) {
                    throw new ApiFailure('invalid_response', $status, $error);
                }
            } catch (TransportExceptionInterface $error) {
                if ($attempt === 3) {
                    throw new ApiFailure('transport', null, $error);
                }

                $this->pause([], $attempt);
            }
        }

        throw new ApiFailure('upstream');
    }

    private function pause(array $headers, int $attempt): void
    {
        $retryAfter = $headers['retry-after'][0] ?? null;
        $milliseconds = is_string($retryAfter) && ctype_digit($retryAfter)
            ? min(5000, (int) $retryAfter * 1000)
            : 250 * (2 ** ($attempt - 1)) + random_int(0, 100);

        ($this->sleep)($milliseconds * 1000);

        $this->logger->warning('Brand extraction retry scheduled', [
            'attempt' => $attempt,
            'delay_ms' => $milliseconds,
        ]);
    }
}

Notice what the log excludes: the token, response body, and full URL. Logging the hostname separately can be useful, but query strings may contain client data and should be omitted.

Persist a structured workspace state

Add nullable brandName, a brandStatus string, a nullable brandError, and JSON columns named logos, colors, fonts, imagery, socialProfiles, and cssVariables to the Workspace entity. Its domain methods should own state transitions:

<?php
// Relevant methods in src/Entity/Workspace.php
use App\Brand\BrandKit;

public function applyBrandKit(BrandKit $kit): void
{
    $this->brandName = $kit->brandName;
    $this->logos = $kit->logos;
    $this->colors = $kit->colors;
    $this->fonts = $kit->fonts;
    $this->imagery = $kit->imagery;
    $this->socialProfiles = $kit->socialProfiles;
    $this->cssVariables = $kit->cssVariables;
    $this->brandStatus = 'ready';
    $this->brandError = null;
}

public function markBrandFailed(string $reason): void
{
    $this->brandStatus = 'failed';
    $this->brandError = $reason;
}

Generate and inspect the migration before applying it:

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction

Dispatch extraction when the workspace is created

The message contains only the database identifier. The handler reloads current state, which avoids serializing an entity into the queue.

<?php
// src/Message/PrefillWorkspaceBrand.php
namespace App\Message;

final readonly class PrefillWorkspaceBrand
{
    public function __construct(public int $workspaceId) {}
}

// src/MessageHandler/PrefillWorkspaceBrandHandler.php
namespace App\MessageHandler;

use App\Entity\Workspace;
use App\Infrastructure\ApiFailure;
use App\Infrastructure\BrandKitExtractor;
use App\Message\PrefillWorkspaceBrand;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class PrefillWorkspaceBrandHandler
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private BrandKitExtractor $extractor,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(PrefillWorkspaceBrand $message): void
    {
        $workspace = $this->entityManager->find(
            Workspace::class,
            $message->workspaceId
        );

        if (!$workspace || $workspace->brandStatus() === 'ready') {
            return;
        }

        try {
            $workspace->applyBrandKit(
                $this->extractor->extract($workspace->siteUrl())
            );
        } catch (ApiFailure $failure) {
            $workspace->markBrandFailed($failure->kind);
            $this->logger->error('Brand extraction failed', [
                'workspace_id' => $message->workspaceId,
                'kind' => $failure->kind,
                'status' => $failure->status,
            ]);
        }

        $this->entityManager->flush();
    }
}

Configure the Doctrine-backed queue in config/packages/messenger.yaml:

framework:
  messenger:
    transports:
      async: 'doctrine://default?queue_name=brand-kit'
    routing:
      App\Message\PrefillWorkspaceBrand: async

After creating and flushing a workspace, the controller dispatches new PrefillWorkspaceBrand($workspace->id()) and returns HTTP 202 with the identifier and brand_status set to pending. Validate that the submitted site URL uses HTTP or HTTPS, contains no user information, and is not localhost or a literal private address. For invite-only products, also confirm that the requester is authorized to create workspaces and consume extraction quota.

Test success, retries, and malformed data

MockHttpClient makes the integration deterministic and prevents tests from reaching the network. The injected sleeper removes retry delays.

<?php
use App\Infrastructure\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 testItRetriesRateLimitAndMapsTheBrandKit(): void
    {
        $payload = [
            'brand_name' => 'Example',
            'logos' => [['url' => 'https://example.com/logo.svg']],
            'colors' => [['value' => '#112233']],
            'fonts' => [['family' => 'Example Sans']],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#112233'],
        ];

        $http = new MockHttpClient([
            new MockResponse('limited', ['http_code' => 429]),
            new MockResponse(json_encode($payload, JSON_THROW_ON_ERROR), [
                'http_code' => 200,
                'response_headers' => ['content-type: application/json'],
            ]),
        ]);

        $client = new BrandKitExtractor(
            $http,
            new NullLogger(),
            'test-token',
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
            static function (int $microseconds): void {},
        );

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

        self::assertSame('Example', $kit->brandName);
        self::assertCount(1, $kit->logos);
        self::assertSame('#112233', $kit->colors[0]['value']);
        self::assertSame('Example Sans', $kit->fonts[0]['family']);
    }
}

Add companion tests asserting that a missing fonts field produces invalid_response, a 401 produces authentication without another request, and the handler persists failed without storing a partial kit. Test the controller separately for invalid URLs, authorization, HTTP 202, and message dispatch.

Operate it like a production feature

Run workers under a process supervisor or your container orchestrator:

php bin/console messenger:consume async \
  --time-limit=3600 \
  --memory-limit=128M \
  --no-interaction

Deploy database migrations before workers containing the new handler. Restart workers after rotating the token or releasing code because long-running PHP processes retain their original environment and loaded classes.

Monitor counts of pending, ready, and failed workspaces, extraction duration, response status classes, and failure kinds. Alert on an accumulating pending queue or sustained authentication failures. A 401 after rotation usually means a worker still has the revoked token; repeated 429 responses indicate exhausted quota or excessive onboarding concurrency; invalid_response points to an upstream contract change or unexpected content; and transport failures usually warrant checking DNS, outbound HTTPS, proxies, and timeout policy.

Do not automatically overwrite later human edits. Once a user customizes a logo, palette, or font selection, record that ownership and make subsequent extraction an explicit refresh with a preview or merge step.

Final verification checklist

  • The service plan is active and the service-scoped token comes from the documentation page’s Service token panel.
  • No token appears in source control, fixtures, logs, screenshots, URLs, or error responses.
  • Workspace creation returns promptly with a persisted pending state.
  • The worker sends exactly a POST request whose JSON body contains url.
  • Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables pass boundary validation before storage.
  • Authentication and validation failures are not retried; transport, rate-limit, and selected upstream failures have bounded backoff.
  • The completed workspace contains its public logo, color data, and font data and reports ready.
  • Workers restart cleanly during deployment and after token rotation.

The visible result is simple: a freshly created workspace already looks like it belongs to the client. The engineering behind it is intentionally less magical. A narrow API boundary, explicit states, conservative retries, and strict validation turn automated brand discovery into dependable product behavior rather than an optimistic HTTP call.

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.