Туториали

Symfony: Brand Kit Extractor API Fuels New Client Workspace Automation

Symfony: API за екстракција на комплет за бренд поттикнува автоматизација на нов работен простор за клиенти

A blank client workspace creates immediate friction. Someone must find the correct logo, copy colors from a stylesheet, identify fonts, and translate scattered clues into usable settings. That work is small enough to be repeatedly underestimated and frequent enough to deserve automation.

This tutorial builds a production-oriented Symfony feature that accepts a client’s public website URL, calls the Brand Kit Extractor API, validates the response at the application boundary, and stores the resulting logo, colors, fonts, imagery, social profiles, and CSS variables. The workspace can then open with a credible visual foundation instead of an empty configuration screen.

Get access before writing integration code

The Brand Kit Extractor API requires a service-scoped credential. It is not a token-free service. Complete the onboarding flow in this order:

  1. Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
  2. Open the Brand Kit Extractor service page.
  3. Choose the 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.

Regenerating that token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed environment promptly, verify the new token, and remove the retired value from your secrets platform.

The API accepts a Bearer token, an X-API-Token header, or a token query parameter. This implementation uses the Bearer form because query parameters are more likely to appear in proxy logs, browser history, and monitoring URLs.

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. Before building the Symfony feature, make one minimal request from a trusted terminal:

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"}'

Do not paste the resulting token or response into tickets, fixtures, screenshots, or source control. A successful request proves that account activation and authentication work independently of Symfony.

Store the credential in .env.local for local development. Symfony normally excludes this file from version control:

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

In production, provide the same variables through the hosting platform’s secret and environment configuration. Never bake the token into an image or committed .env file.

Choose a deliberately small architecture

This workflow has four responsibilities: the controller validates the local request and authorization, an HTTP client owns the external protocol, a domain mapper validates remote data, and the workspace entity persists the accepted result. Keeping those boundaries separate makes malformed upstream responses testable without involving Doctrine or a real network.

The extraction runs synchronously here because it is a single user-initiated setup action and the controller has a bounded total request duration. If the surrounding product already handles onboarding asynchronously, the same service can be called from Symfony Messenger. Adding a queue solely for one bounded request would create more deployment and failure states than this ordinary workspace flow needs.

Start with PHP 8.3 or later, Composer, a Symfony application, and a configured Doctrine database:

composer require symfony/http-client symfony/orm-pack symfony/validator
composer require --dev symfony/test-pack symfony/maker-bundle

php bin/console about
php bin/console doctrine:schema:validate

The relevant project structure is intentionally compact:

src/
  BrandKit/BrandKit.php
  BrandKit/BrandKitClient.php
  BrandKit/BrandKitException.php
  Controller/WorkspaceBrandKitController.php
  Entity/Workspace.php
tests/
  BrandKit/BrandKitClientTest.php
config/
  services.yaml

Map untrusted JSON into a domain object

An HTTP 200 response is not permission to store arbitrary JSON. The boundary must verify the documented brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Empty arrays remain valid because a public site may not expose every category.

The following mapper requires those members, checks their broad JSON types, limits total payload size, and selects a usable HTTP or HTTPS logo without assuming every logo item has the same representation:

<?php
// src/BrandKit/BrandKit.php

namespace App\BrandKit;

final readonly class BrandKit
{
    private const MAX_JSON_BYTES = 250_000;

    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 fromApiPayload(array $payload): self
    {
        $brandName = $payload['brand_name'] ?? null;

        if ($brandName !== null && (!is_string($brandName) || trim($brandName) === '')) {
            throw new \UnexpectedValueException('brand_name must be a non-empty string or null.');
        }

        $kit = new self(
            $brandName === null ? null : trim($brandName),
            self::arrayMember($payload, 'logos'),
            self::arrayMember($payload, 'colors'),
            self::arrayMember($payload, 'fonts'),
            self::arrayMember($payload, 'imagery'),
            self::arrayMember($payload, 'social_profiles'),
            self::arrayMember($payload, 'css_variables'),
        );

        $encoded = json_encode($payload, JSON_THROW_ON_ERROR);

        if (strlen($encoded) > self::MAX_JSON_BYTES) {
            throw new \UnexpectedValueException('Brand kit payload is too large.');
        }

        return $kit;
    }

    public function primaryLogo(): ?string
    {
        foreach ($this->logos as $logo) {
            $candidate = is_string($logo)
                ? $logo
                : (is_array($logo) && is_string($logo['url'] ?? null)
                    ? $logo['url']
                    : null);

            if ($candidate !== null
                && filter_var($candidate, FILTER_VALIDATE_URL)
                && in_array(
                    strtolower((string) parse_url($candidate, PHP_URL_SCHEME)),
                    ['http', 'https'],
                    true
                )
            ) {
                return $candidate;
            }
        }

        return null;
    }

    private static function arrayMember(array $payload, string $key): array
    {
        if (!array_key_exists($key, $payload) || !is_array($payload[$key])) {
            throw new \UnexpectedValueException(sprintf('%s must be an array.', $key));
        }

        return $payload[$key];
    }
}

This is also the only place that needs adjustment if the official documentation later versions or envelopes the response. Controllers and entities should never become coupled to raw transport JSON.

Build a bounded, retry-aware API client

The client sends only the documented url member. It retries transport failures, HTTP 429 responses, and server failures with short bounded backoff. It does not retry authentication errors or validation failures: those require a configuration or input change, not another identical request.

<?php
// src/BrandKit/BrandKitException.php

namespace App\BrandKit;

final class BrandKitException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly ?int $status = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
<?php
// src/BrandKit/BrandKitClient.php

namespace App\BrandKit;

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

final class BrandKitClient
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $brandKitEndpoint,
        private string $brandKitToken,
    ) {
    }

    public function extract(string $url): BrandKit
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', $this->brandKitEndpoint, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->brandKitToken,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 10.0,
                    'max_duration' => 25.0,
                ]);

                $status = $response->getStatusCode();

                if (($status === 429 || $status >= 500) && $attempt < 3) {
                    $this->logger->warning('Brand kit request will be retried.', [
                        'status' => $status,
                        'attempt' => $attempt,
                    ]);
                    usleep($this->backoffMicros($response->getHeaders(false), $attempt));
                    continue;
                }

                if ($status === 401 || $status === 403) {
                    throw new BrandKitException(
                        'authentication',
                        'The brand kit service rejected its credential.',
                        $status
                    );
                }

                if ($status === 429) {
                    throw new BrandKitException(
                        'quota',
                        'The brand kit service is rate limited.',
                        $status
                    );
                }

                if ($status < 200 || $status >= 300) {
                    throw new BrandKitException(
                        $status >= 500 ? 'upstream_unavailable' : 'request_rejected',
                        'The brand kit service rejected the request.',
                        $status
                    );
                }

                try {
                    $payload = $response->toArray(false);
                    return BrandKit::fromApiPayload($payload);
                } catch (DecodingExceptionInterface|\UnexpectedValueException $exception) {
                    throw new BrandKitException(
                        'invalid_response',
                        'The brand kit service returned an invalid response.',
                        $status,
                        $exception
                    );
                }
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new BrandKitException(
                        'transport',
                        'The brand kit service could not be reached.',
                        null,
                        $exception
                    );
                }

                $this->logger->warning('Brand kit transport failure; retrying.', [
                    'attempt' => $attempt,
                    'exception_class' => $exception::class,
                ]);
                usleep(250_000 * $attempt);
            }
        }

        throw new \LogicException('Unreachable retry state.');
    }

    private function backoffMicros(array $headers, int $attempt): int
    {
        $retryAfter = $headers['retry-after'][0] ?? null;

        if (is_string($retryAfter) && ctype_digit($retryAfter)) {
            return min((int) $retryAfter, 2) * 1_000_000;
        }

        return 250_000 * $attempt;
    }
}

Wire the scalar arguments explicitly. Symfony injects the HTTP client and logger by type:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

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

    App\BrandKit\BrandKitClient:
        arguments:
            $brandKitEndpoint: '%env(BRAND_KIT_ENDPOINT)%'
            $brandKitToken: '%env(BRAND_KIT_TOKEN)%'

Apply the kit to an authorized workspace

Add nullable brand fields to the existing Workspace entity. JSON columns preserve the evidence-rich arrays while primaryLogoUrl gives the interface a convenient, validated default.

<?php
// Relevant additions to src/Entity/Workspace.php

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

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

#[ORM\Column(type: 'json')]
private array $brandColors = [];

#[ORM\Column(type: 'json')]
private array $brandFonts = [];

#[ORM\Column(type: 'json')]
private array $brandImagery = [];

#[ORM\Column(type: 'json')]
private array $brandSocialProfiles = [];

#[ORM\Column(type: 'json')]
private array $brandCssVariables = [];

public function applyBrandKit(\App\BrandKit\BrandKit $kit): void
{
    $this->brandName = $kit->brandName;
    $this->primaryLogoUrl = $kit->primaryLogo();
    $this->brandColors = $kit->colors;
    $this->brandFonts = $kit->fonts;
    $this->brandImagery = $kit->imagery;
    $this->brandSocialProfiles = $kit->socialProfiles;
    $this->brandCssVariables = $kit->cssVariables;
}

The controller checks workspace ownership through a voter, validates that the submitted value is a public-style HTTP URL, calls the boundary service, and flushes only fully mapped data:

<?php
// src/Controller/WorkspaceBrandKitController.php

namespace App\Controller;

use App\BrandKit\BrandKitClient;
use App\BrandKit\BrandKitException;
use App\Repository\WorkspaceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class WorkspaceBrandKitController extends AbstractController
{
    #[Route('/workspaces/{id}/brand-kit', methods: ['POST'])]
    public function __invoke(
        int $id,
        Request $request,
        WorkspaceRepository $workspaces,
        BrandKitClient $client,
        EntityManagerInterface $entityManager,
    ): JsonResponse {
        $workspace = $workspaces->find($id);

        if ($workspace === null) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('EDIT', $workspace);

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

        $url = $input['url'] ?? null;
        $scheme = is_string($url) ? strtolower((string) parse_url($url, PHP_URL_SCHEME)) : '';

        if (!is_string($url)
            || filter_var($url, FILTER_VALIDATE_URL) === false
            || !in_array($scheme, ['http', 'https'], true)
        ) {
            return $this->json(['error' => 'invalid_public_url'], 422);
        }

        try {
            $kit = $client->extract($url);
            $workspace->applyBrandKit($kit);
            $entityManager->flush();
        } catch (BrandKitException $exception) {
            $status = $exception->kind === 'quota' ? 429 : 502;

            return $this->json([
                'error' => 'brand_kit_unavailable',
                'reason' => $exception->kind,
                'retryable' => in_array(
                    $exception->kind,
                    ['quota', 'transport', 'upstream_unavailable'],
                    true
                ),
            ], $status);
        }

        return $this->json([
            'workspace_id' => $id,
            'brand_name' => $kit->brandName,
            'primary_logo_url' => $kit->primaryLogo(),
            'colors' => $kit->colors,
            'fonts' => $kit->fonts,
        ]);
    }
}

For stricter input governance, reject loopback and private network addresses or allow only domains confirmed by the workspace owner. Although your server sends the URL to the API rather than fetching it directly, accepting arbitrary internal-looking destinations is still unnecessary and can disclose sensitive names to an external service.

Test the protocol without calling production

MockHttpClient makes the integration deterministic. The test verifies the method, endpoint, authentication header, JSON request, and domain mapping:

<?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 testItMapsAValidatedBrandKit(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url, array $options): MockResponse {
                self::assertSame('POST', $method);
                self::assertSame('https://service.test/extract', $url);
                self::assertStringContainsString(
                    'Authorization: Bearer test-token',
                    implode("\n", $options['headers'])
                );
                self::assertSame(
                    ['url' => 'https://example.com'],
                    json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR)
                );

                return new MockResponse(json_encode([
                    'brand_name' => 'Example',
                    'logos' => [['url' => 'https://example.com/logo.svg']],
                    'colors' => ['#112233'],
                    'fonts' => ['Inter'],
                    'imagery' => [],
                    'social_profiles' => [],
                    'css_variables' => ['--brand-primary' => '#112233'],
                ], JSON_THROW_ON_ERROR), [
                    'http_code' => 200,
                    'response_headers' => ['content-type: application/json'],
                ]);
            }
        );

        $client = new BrandKitClient(
            $http,
            new NullLogger(),
            'https://service.test/extract',
            'test-token'
        );

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

        self::assertSame('Example', $kit->brandName);
        self::assertSame('#112233', $kit->colors[0]);
        self::assertSame('https://example.com/logo.svg', $kit->primaryLogo());
    }

    public function testItRejectsAnIncompleteResponse(): void
    {
        $http = new MockHttpClient(new MockResponse(
            '{"brand_name":"Incomplete"}',
            ['http_code' => 200]
        ));

        $client = new BrandKitClient(
            $http,
            new NullLogger(),
            'https://service.test/extract',
            'test-token'
        );

        $this->expectException(\App\BrandKit\BrandKitException::class);
        $client->extract('https://example.com');
    }
}

Add controller tests for an unauthorized user, malformed JSON, an invalid URL, upstream quota exhaustion, and successful persistence. The service test must never depend on a live account or consume plan quota.

Deploy with operational guardrails

Generate and inspect the Doctrine migration locally, run the test suite, and apply the committed migration during deployment:

php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
php bin/phpunit
php bin/console cache:clear --env=prod

Log the workspace identifier, upstream status, attempt number, failure category, and duration where available. Do not log the Authorization header, complete response, or submitted query string. Alert on sustained authentication failures, repeated 429 responses, transport failures, and invalid response shapes; each points to a different owner and remedy.

Common failures are predictable:

  • 401 or 403: the token is missing, revoked, copied incorrectly, or belongs to the wrong service. Do not retry blindly.
  • 429: the active plan or request rate is exhausted. Respect bounded retry timing and let the user try later.
  • 400 or 422: the submitted URL is unacceptable. Correct the input instead of repeating it.
  • 5xx or transport failure: retry briefly, then preserve the existing workspace values and return a retryable failure.
  • Invalid JSON or missing members: treat this as a contract failure. Store nothing and investigate before weakening validation.
  • No usable logo: keep the validated colors and fonts, leave the primary logo nullable, and offer manual selection or upload.

Final verification checklist

  • The service plan is active and the service-scoped token is supplied through environment-backed configuration.
  • The application calls the exact POST endpoint with a JSON url member.
  • Connection and total response time are bounded.
  • Only transient failures are retried, with short capped backoff.
  • All seven response categories are validated before Doctrine receives them.
  • Workspace authorization runs before any external request or mutation.
  • Tests use MockHttpClient and contain no real credential.
  • Logs expose failure categories without exposing tokens or raw brand data.
  • A real staging workspace opens with its public logo, colors, and fonts prefilled.

The best onboarding automation does not try to make irreversible design decisions. It removes the blank page. By treating extracted brand data as validated evidence, preserving failure boundaries, and keeping every field editable, this Symfony integration gives a new workspace a useful starting point without pretending that automation replaces judgment.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.