Vodiči

Symfony Brand Kit Automation: Pre-Populate Client Workspaces

Automatizacija Symfony Brand Kita: unaprijed popunite radne prostore klijenata

A blank client workspace creates an awkward first impression. The customer has already entered a website address, yet the setup screen still asks them to upload a logo, find color values, and identify fonts they may not know by name.

A better onboarding flow uses the website as the starting point. In this tutorial, a Symfony application sends a public URL to the Brand Kit Extractor API, validates the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables, then stores the result on the workspace. The extraction runs through Messenger, so onboarding remains responsive even when the remote website is slow.

The design is deliberately modest: one controller, one queued message, a strict API boundary, and JSON columns for evidence-based data that may evolve independently of your application.

Get access before writing integration code

First, register an account or sign in. Open the Brand Kit Extractor service page, choose an available Free, Plus, or Pro plan, and complete its activation.

Next, open the official service documentation. Find the Service token panel and copy the service-scoped token shown there. This service requires authentication; it is not a token-free API.

Regenerating the token revokes the previously active token. Treat rotation as a deployment change: update every running environment before relying on the new credential, and never place either token in source control, fixtures, logs, or screenshots.

The exact operation used by this project is:

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

The API accepts Bearer authentication, an X-API-Token header, or a token query parameter. This implementation uses a Bearer token because headers are less likely than query strings to appear in proxy and access logs.

Before building the feature, verify the account and token with a minimal request:

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 "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'

Use a public website you are entitled to process. A successful response should contain the brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Do not assume that success alone makes every value suitable for storage; the application boundary will verify the response shape.

Put the real credential in .env.local for local development, not in the committed .env file:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=brand_kit

In production, inject the same variables through the deployment platform’s secret manager.

Create the Symfony project

The application requires PHP 8.3 or newer, Composer, a Symfony application, and a Doctrine-supported database. Starting from an empty directory, install only the components that serve the feature:

composer create-project symfony/skeleton brand-workspaces
cd brand-workspaces
composer require symfony/framework-bundle symfony/http-client symfony/messenger \
  symfony/doctrine-messenger doctrine/doctrine-bundle doctrine/orm \
  doctrine/doctrine-migrations-bundle symfony/validator
composer require --dev symfony/test-pack

The resulting feature has four layers:

  • The controller creates a workspace and dispatches extraction work.
  • Messenger moves remote I/O out of the request path.
  • A dedicated client owns authentication, timeouts, retries, and HTTP failures.
  • A mapper rejects incomplete or malformed responses before Doctrine sees them.

JSON columns are a useful trade-off here. Logos, colors, and fonts can contain richer evidence than a single URL or hex value, and flattening them prematurely would discard information. If the application later needs queries such as “find every workspace using this font,” promote that particular concept into normalized tables.

Define a strict domain boundary

Create src/Brand/BrandKit.php. The mapper deliberately validates only the documented top-level contract. It preserves nested values rather than inventing an undocumented logo, color, or font schema.

<?php

namespace App\Brand;

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,
    ) {}
}

final class InvalidBrandKit extends \RuntimeException {}

final class BrandKitMapper
{
    public function map(array $data): BrandKit
    {
        if (!isset($data['brand_name'])
            || !is_string($data['brand_name'])
            || trim($data['brand_name']) === '') {
            throw new InvalidBrandKit('Response has no valid brand_name.');
        }

        $arrays = [
            'logos',
            'colors',
            'fonts',
            'imagery',
            'social_profiles',
            'css_variables',
        ];

        foreach ($arrays as $field) {
            if (!array_key_exists($field, $data) || !is_array($data[$field])) {
                throw new InvalidBrandKit(
                    sprintf('Response field "%s" must be an array.', $field)
                );
            }
        }

        return new BrandKit(
            trim($data['brand_name']),
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['social_profiles'],
            $data['css_variables'],
        );
    }
}

This is an intentional fail-closed boundary. An empty array is valid because a public site may expose no reliable font or social-profile evidence. A missing field or wrong type is not valid, because storing a partial response would make a transport problem look like legitimate brand data.

Build the resilient HTTP client

Create src/Brand/BrandKitClient.php. It sends exactly one JSON parameter, url, and bounds both idle network time and total request duration. It retries transport failures, HTTP 429, and server errors, but never retries authentication, authorization, or other client-validation failures.

<?php

namespace App\Brand;

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

final class BrandKitRequestFailed extends \RuntimeException {}

final class BrandKitClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';

    public function __construct(
        private HttpClientInterface $http,
        private string $token,
        private BrandKitMapper $mapper,
        private LoggerInterface $logger,
    ) {}

    public function extract(string $url): BrandKit
    {
        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' => 10.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();

                if ($status === 429 || $status >= 500) {
                    if ($attempt === 3) {
                        throw new BrandKitRequestFailed(
                            sprintf('Extractor remained unavailable (HTTP %d).', $status)
                        );
                    }

                    $headers = $response->getHeaders(false);
                    $retryAfter = (int) ($headers['retry-after'][0] ?? 0);
                    $delayMs = $retryAfter > 0
                        ? min($retryAfter * 1000, 2000)
                        : 250 * (2 ** ($attempt - 1));

                    $this->logger->warning('Brand extraction will be retried.', [
                        'attempt' => $attempt,
                        'status' => $status,
                        'delay_ms' => $delayMs,
                    ]);
                    usleep($delayMs * 1000);
                    continue;
                }

                if ($status < 200 || $status >= 300) {
                    throw new BrandKitRequestFailed(
                        sprintf('Extractor rejected the request (HTTP %d).', $status)
                    );
                }

                try {
                    $data = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException $e) {
                    throw new BrandKitRequestFailed(
                        'Extractor returned invalid JSON.',
                        previous: $e
                    );
                }

                if (!is_array($data)) {
                    throw new BrandKitRequestFailed(
                        'Extractor returned a non-object JSON value.'
                    );
                }

                return $this->mapper->map($data);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new BrandKitRequestFailed(
                        'Extractor could not be reached.',
                        previous: $e
                    );
                }

                $delayMs = 250 * (2 ** ($attempt - 1));
                $this->logger->warning('Brand extraction transport failure.', [
                    'attempt' => $attempt,
                    'delay_ms' => $delayMs,
                ]);
                usleep($delayMs * 1000);
            }
        }

        throw new BrandKitRequestFailed('Brand extraction failed.');
    }
}

The client does not log the token, response body, or complete URL. URLs may contain customer identifiers or query strings, while response data can reveal business accounts and design assets. Logs should record operational facts, not duplicate payloads.

Wire the scalar credential through config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Brand\BrandKitClient:
        arguments:
            $token: '%env(string:BRAND_KIT_TOKEN)%'

Persist the complete result

The workspace entity should keep its original website URL and an explicit extraction state. Add Doctrine JSON properties for every validated response field, not merely the three currently displayed by onboarding:

<?php

namespace App\Entity;

use App\Brand\BrandKit;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Workspace
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 160)]
    private string $name;

    #[ORM\Column(length: 2048)]
    private string $websiteUrl;

    #[ORM\Column(length: 20)]
    private string $brandStatus = 'pending';

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $brandName = null;

    #[ORM\Column(type: 'json')] private array $logos = [];
    #[ORM\Column(type: 'json')] private array $colors = [];
    #[ORM\Column(type: 'json')] private array $fonts = [];
    #[ORM\Column(type: 'json')] private array $imagery = [];
    #[ORM\Column(type: 'json')] private array $socialProfiles = [];
    #[ORM\Column(type: 'json')] private array $cssVariables = [];

    #[ORM\Column(length: 500, nullable: true)]
    private ?string $brandFailure = null;

    public function __construct(string $name, string $websiteUrl)
    {
        $this->name = $name;
        $this->websiteUrl = $websiteUrl;
    }

    public function getId(): ?int { return $this->id; }
    public function getWebsiteUrl(): string { return $this->websiteUrl; }

    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->brandFailure = null;
    }

    public function failBrandExtraction(string $reason): void
    {
        $this->brandStatus = 'failed';
        $this->brandFailure = mb_substr($reason, 0, 500);
    }
}

Generate and inspect the migration before applying it:

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

Queue extraction after workspace creation

Create a small message and handler in src/Message/ExtractWorkspaceBrand.php and src/MessageHandler/ExtractWorkspaceBrandHandler.php:

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

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

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

use App\Brand\BrandKitClient;
use App\Entity\Workspace;
use App\Message\ExtractWorkspaceBrand;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class ExtractWorkspaceBrandHandler
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private BrandKitClient $client,
        private LoggerInterface $logger,
    ) {}

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

        if (!$workspace) {
            $this->logger->notice('Brand extraction skipped; workspace is absent.', [
                'workspace_id' => $message->workspaceId,
            ]);
            return;
        }

        try {
            $workspace->applyBrandKit(
                $this->client->extract($workspace->getWebsiteUrl())
            );
            $this->logger->info('Workspace brand kit populated.', [
                'workspace_id' => $message->workspaceId,
            ]);
        } catch (\Throwable $e) {
            $workspace->failBrandExtraction($e->getMessage());
            $this->logger->error('Workspace brand extraction failed.', [
                'workspace_id' => $message->workspaceId,
                'exception_class' => $e::class,
            ]);
        }

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

Configure asynchronous delivery in config/packages/messenger.yaml:

framework:
    messenger:
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
            App\Message\ExtractWorkspaceBrand: async

Your authenticated workspace-creation controller should validate that url is an HTTP or HTTPS URL with a hostname, persist the workspace, flush it to obtain its ID, and only then dispatch:

$workspace = new Workspace($name, $url);
$entityManager->persist($workspace);
$entityManager->flush();

$messageBus->dispatch(
    new ExtractWorkspaceBrand($workspace->getId())
);

Require authorization on this route, reject direct private or loopback addresses, and accept only websites the customer is authorized to process. The remote service fetches a public site, but your application still controls who can enqueue work and how much work one account can create.

Test the boundary without making network calls

MockHttpClient makes the test deterministic and verifies the outbound contract:

<?php

namespace App\Tests\Brand;

use App\Brand\BrandKitClient;
use App\Brand\BrandKitMapper;
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 testItMapsACompleteBrandKit(): void
    {
        $response = new MockResponse(json_encode([
            'brand_name' => 'Example',
            'logos' => [['url' => 'https://example.com/logo.svg']],
            'colors' => [['value' => '#112233']],
            'fonts' => [['family' => 'Example Sans']],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-color' => '#112233'],
        ], JSON_THROW_ON_ERROR), ['http_code' => 200]);

        $http = new MockHttpClient(
            function (string $method, string $url, array $options) use ($response) {
                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::assertSame(
                    ['url' => 'https://example.com'],
                    $options['json']
                );

                return $response;
            }
        );

        $client = new BrandKitClient(
            $http,
            'test-token',
            new BrandKitMapper(),
            new NullLogger()
        );

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

        self::assertSame('Example', $kit->brandName);
        self::assertCount(1, $kit->logos);
        self::assertCount(1, $kit->colors);
        self::assertCount(1, $kit->fonts);
    }
}

Add mapper tests for a missing field, an invalid brand_name, and non-array collections. Add handler tests covering a missing workspace, successful persistence, and a structured failed state when the client throws. Run them with:

php bin/phpunit

Production reliability and common failures

Run the worker under systemd, Supervisor, a container orchestrator, or your hosting platform’s worker facility:

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

Deploy the database migration before starting workers that contain the new handler. Restart workers after each release so they load current code and rotated secrets.

Watch counts and latency for ready and failed workspaces, HTTP status categories, retry attempts, and queue age. Alerting on a growing queue catches a stopped worker; alerting on authentication failures catches an expired or regenerated token. Keep log context to workspace IDs and status metadata.

The most common failure modes have distinct remedies:

  • HTTP 401 or 403: verify plan activation and the service token. Do not retry automatically.
  • HTTP 429: respect the bounded Retry-After delay, then leave the workspace failed if the limit persists. Offer an explicit retry action later.
  • HTTP 400-range validation errors: recheck the public URL and request body; repeated identical calls will not repair them.
  • Timeouts or HTTP 500-range responses: allow the client’s bounded backoff, while keeping the web request independent of the result.
  • Malformed successful responses: reject them before storage and preserve the existing workspace data.

Final verification checklist

  1. Create a workspace with an authorized public HTTP or HTTPS website.
  2. Confirm the web request returns without waiting for extraction.
  3. Run the Messenger worker and verify the message is consumed.
  4. Confirm the workspace reaches ready.
  5. Verify the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables were stored.
  6. Confirm the onboarding interface prefills its logo, color, and font controls from those stored values.
  7. Test an invalid token and confirm there is no blind retry loop or credential leakage.
  8. Test a malformed response with MockHttpClient and confirm no partial kit is accepted.

The polished part of this feature is not the HTTP call. It is the boundary around it: asynchronous execution, restrained retries, complete validation, evidence-preserving storage, and a failure state the customer can recover from. With those pieces in place, a website address stops being another form field and becomes the seed for a workspace that already feels like it belongs to the client.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.