Tutorials

Symfony: Inject Verified Brand Assets into Proposals with Brand Kit API

Symfony: Inject Verified Brand Assets into Proposals with Brand Kit API

A proposal generator becomes surprisingly fragile when branding arrives as “the blue from the website” and a logo copied from an email signature. Assets drift, colors are guessed, and every new client creates another manual cleanup task.

This tutorial replaces that workflow with a production-oriented Symfony integration. A console command submits a client’s public website to the Brand Kit Extractor API, validates the response at the application boundary, and stores a normalized snapshot for an everyday HTML proposal or report generator. The design favors explicit failure states, bounded network calls, deterministic tests, and a storage layer that can evolve without coupling the rest of the application to the external response.

Get access and create a service token

Register through the registration page, or use the sign-in page if you already have an account.

  1. Open the Brand Kit Extractor service page.
  2. Choose the available Free, Plus, or Pro plan and complete activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.

The service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. This implementation uses the Bearer form because it keeps credentials out of URLs, access logs, browser history, and proxy analytics.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update the secret store, deploy or restart consumers, verify the new token, and only then consider the change complete.

Confirm access with the exact endpoint

The API call is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url. Test it before adding framework code:

curl --request POST \
  '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://example.com"}'

Do not paste a real token into source control or shell-history examples. In the Symfony project, put it in .env.local for local development:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Production should inject the same variable through the hosting platform’s secret manager. Commit only a non-secret placeholder, such as BRAND_KIT_TOKEN=, in .env.

Architecture for a practical proposal generator

This implementation assumes PHP 8.3 or later and an existing Symfony application with Console, HttpClient, and Filesystem available. Install missing components with:

composer require symfony/http-client symfony/console symfony/filesystem
composer require --dev symfony/test-pack

The import remains synchronous because it is an operator-triggered console task, not part of an interactive request. That avoids introducing Messenger solely to move a short command elsewhere. If imports later originate from customer-facing HTTP requests, Messenger becomes worthwhile: enqueue the URL, return immediately, and run this same application service in a worker.

The project has four boundaries:

  • BrandKitMapper rejects malformed or incomplete external data.
  • BrandKitExtractor owns authentication, timeouts, retries, and HTTP failure classification.
  • BrandKitStore persists the application’s stable representation.
  • ImportBrandKitCommand connects the importer to the proposal workflow.

A file-backed store is sufficient for one application instance or a shared durable volume. Multiple replicas should replace only this repository with database or object-storage persistence; neither the API client nor proposal renderer needs to change.

Map the external response into a domain object

The contract supplies brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. External JSON should never flow directly into Twig or persistence. The mapper below canonicalizes only the supplied top-level field names, requires every field, and enforces conservative types.

Place these classes in src/BrandKit/BrandKit.php and src/BrandKit/BrandKitMapper.php:

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

final readonly class BrandKit implements \JsonSerializable
{
    public function __construct(
        public string $sourceUrl,
        public string $brandName,
        public array $logos,
        public array $colors,
        public array $fonts,
        public array $imagery,
        public array $socialProfiles,
        public array $cssVariables,
        public array $logoUrls,
    ) {}

    public function jsonSerialize(): array
    {
        return [
            'source_url' => $this->sourceUrl,
            'brand_name' => $this->brandName,
            'logos' => $this->logos,
            'colors' => $this->colors,
            'fonts' => $this->fonts,
            'imagery' => $this->imagery,
            'social_profiles' => $this->socialProfiles,
            'css_variables' => $this->cssVariables,
            'logo_urls' => $this->logoUrls,
        ];
    }
}
<?php
// src/BrandKit/BrandKitMapper.php
namespace App\BrandKit;

final class BrandKitMapper
{
    public function map(string $sourceUrl, array $payload): BrandKit
    {
        $fields = [];

        foreach ($payload as $name => $value) {
            if (is_string($name)) {
                $canonical = strtolower(
                    preg_replace('/[^a-z0-9]+/i', '', $name) ?? ''
                );
                $fields[$canonical] = $value;
            }
        }

        $brandName = $this->required($fields, 'brandname');
        if (!is_string($brandName) || trim($brandName) === '') {
            throw new \UnexpectedValueException('Invalid brand name');
        }

        $arrays = [];
        foreach ([
            'logos', 'colors', 'fonts', 'imagery',
            'socialprofiles', 'cssvariables',
        ] as $field) {
            $value = $this->required($fields, $field);
            if (!is_array($value)) {
                throw new \UnexpectedValueException(
                    sprintf('Invalid brand-kit field: %s', $field)
                );
            }
            $arrays[$field] = $value;
        }

        return new BrandKit(
            $sourceUrl,
            trim($brandName),
            $arrays['logos'],
            $arrays['colors'],
            $arrays['fonts'],
            $arrays['imagery'],
            $arrays['socialprofiles'],
            $arrays['cssvariables'],
            $this->collectHttpUrls($arrays['logos']),
        );
    }

    private function required(array $fields, string $name): mixed
    {
        if (!array_key_exists($name, $fields)) {
            throw new \UnexpectedValueException(
                sprintf('Missing brand-kit field: %s', $name)
            );
        }

        return $fields[$name];
    }

    private function collectHttpUrls(array $values): array
    {
        $urls = [];

        array_walk_recursive($values, static function (mixed $value) use (&$urls): void {
            if (!is_string($value) || filter_var($value, FILTER_VALIDATE_URL) === false) {
                return;
            }

            $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
            if ($scheme === 'http' || $scheme === 'https') {
                $urls[] = $value;
            }
        });

        return array_values(array_unique($urls));
    }
}

The URL collector does not assume an undocumented nested logo shape. It derives renderable HTTP URLs from whatever structured logo evidence the response contains. Empty collections remain valid because some public sites may not expose every asset category.

Build a bounded, retry-aware API client

The client retries only rate limiting and temporary server failures. Authentication errors and invalid requests are deterministic until configuration or input changes, so retrying them merely wastes quota and delays diagnosis.

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

use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class BrandKitImportException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        string $message,
    ) {
        parent::__construct($message);
    }
}

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

    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $token,
        private readonly BrandKitMapper $mapper,
        ?\Closure $sleep = null,
    ) {
        if (trim($token) === '') {
            throw new \InvalidArgumentException('BRAND_KIT_TOKEN is empty');
        }

        $this->sleep = $sleep
            ?? static fn (int $milliseconds) => usleep($milliseconds * 1000);
    }

    public function extract(string $url): BrandKit
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
        ) {
            throw new \InvalidArgumentException('A public HTTP or HTTPS URL is required');
        }

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->token,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $exception) {
                if ($attempt < 2) {
                    ($this->sleep)(250 * (2 ** $attempt));
                    continue;
                }

                throw new BrandKitImportException(
                    'transport', true, 'Brand-kit service was unreachable'
                );
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );

                    if (!is_array($payload)) {
                        throw new \JsonException('Expected a JSON object');
                    }

                    return $this->mapper->map($url, $payload);
                } catch (\JsonException|\UnexpectedValueException $exception) {
                    throw new BrandKitImportException(
                        'invalid_response', false, $exception->getMessage()
                    );
                }
            }

            if ($status === 401 || $status === 403) {
                throw new BrandKitImportException(
                    'authentication', false, 'Service token was rejected'
                );
            }

            if ($status === 400 || $status === 422) {
                throw new BrandKitImportException(
                    'invalid_request', false, 'The website URL was rejected'
                );
            }

            if ($status === 429 || $status >= 500) {
                if ($attempt < 2) {
                    $headers = $response->getHeaders(false);
                    $retryAfter = $headers['retry-after'][0] ?? null;
                    $delay = ctype_digit((string) $retryAfter)
                        ? min(2000, (int) $retryAfter * 1000)
                        : 250 * (2 ** $attempt);

                    ($this->sleep)($delay);
                    continue;
                }

                throw new BrandKitImportException(
                    $status === 429 ? 'rate_limit' : 'upstream',
                    true,
                    'Temporary brand-kit service failure'
                );
            }

            throw new BrandKitImportException(
                'http', false, sprintf('Unexpected HTTP status %d', $status)
            );
        }

        throw new \LogicException('Retry loop ended unexpectedly');
    }
}

The five-second timeout bounds connection and network inactivity, while max_duration caps the total request. Retry delays are also capped. Response bodies are deliberately absent from exceptions because an upstream body may contain sensitive diagnostics or excessive data.

Persist snapshots and expose an import command

Configure dependency injection without committing the credential:

# config/services.yaml
services:
    App\BrandKit\BrandKitExtractor:
        arguments:
            $token: '%env(BRAND_KIT_TOKEN)%'

    App\BrandKit\BrandKitStore:
        arguments:
            $directory: '%kernel.project_dir%/var/brand-kits'

The repository uses Filesystem::dumpFile() so readers do not observe a partially written JSON document.

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

use Symfony\Component\Filesystem\Filesystem;

final class BrandKitStore
{
    public function __construct(
        private readonly string $directory,
        private readonly Filesystem $filesystem,
    ) {}

    public function save(string $customer, BrandKit $kit): void
    {
        $this->assertCustomer($customer);
        $this->filesystem->mkdir($this->directory, 0750);
        $this->filesystem->dumpFile(
            $this->directory.'/'.$customer.'.json',
            json_encode(
                $kit,
                JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
            )
        );
    }

    public function find(string $customer): ?array
    {
        $this->assertCustomer($customer);
        $path = $this->directory.'/'.$customer.'.json';

        if (!is_file($path)) {
            return null;
        }

        $value = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);

        return is_array($value) ? $value : null;
    }

    private function assertCustomer(string $customer): void
    {
        if (!preg_match('/^[a-z0-9][a-z0-9-]{0,63}$/', $customer)) {
            throw new \InvalidArgumentException('Invalid customer identifier');
        }
    }
}
<?php
// src/Command/ImportBrandKitCommand.php
namespace App\Command;

use App\BrandKit\BrandKitExtractor;
use App\BrandKit\BrandKitImportException;
use App\BrandKit\BrandKitStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:brand-kit:import')]
final class ImportBrandKitCommand extends Command
{
    public function __construct(
        private readonly BrandKitExtractor $extractor,
        private readonly BrandKitStore $store,
        private readonly LoggerInterface $logger,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('customer', InputArgument::REQUIRED)
            ->addArgument('url', InputArgument::REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $customer = (string) $input->getArgument('customer');
        $url = (string) $input->getArgument('url');

        try {
            $kit = $this->extractor->extract($url);
            $this->store->save($customer, $kit);
        } catch (BrandKitImportException $exception) {
            $this->logger->error('Brand-kit import failed', [
                'customer' => $customer,
                'host' => parse_url($url, PHP_URL_HOST),
                'kind' => $exception->kind,
                'retryable' => $exception->retryable,
            ]);
            $output->writeln('<error>Import failed: '.$exception->kind.'</error>');

            return Command::FAILURE;
        }

        $this->logger->info('Brand-kit import completed', [
            'customer' => $customer,
            'brand_name' => $kit->brandName,
        ]);
        $output->writeln('Imported brand kit for '.$kit->brandName);

        return Command::SUCCESS;
    }
}

Run php bin/console app:brand-kit:import acme https://www.example.com. The resulting var/brand-kits/acme.json becomes the proposal generator’s stable input.

Inject safe values into proposals

A renderer should not turn arbitrary extracted CSS into executable stylesheet text. Select values intentionally, validate them, and retain a neutral fallback. The same principle applies to logo URLs: Twig escaping protects HTML syntax, but a server-side PDF engine must not fetch remote assets without separate SSRF controls and an approved downloader.

<?php
$brand = $brandKitStore->find($customer)
    ?? throw $this->createNotFoundException('Brand kit not imported');

$accent = $brand['css_variables']['--primary-color'] ?? '#1f2937';
if (!is_string($accent) || !preg_match('/^#[0-9a-f]{6}$/i', $accent)) {
    $accent = '#1f2937';
}

return $this->render('proposal/show.html.twig', [
    'proposal' => $proposal,
    'brandName' => $brand['brand_name'],
    'logoUrl' => $brand['logo_urls'][0] ?? null,
    'accent' => $accent,
]);
<article style="--proposal-accent: {{ accent }}">
  {% if logoUrl %}
    <img src="{{ logoUrl }}" alt="{{ brandName }} logo">
  {% endif %}
  <h1>Proposal for {{ brandName }}</h1>
  {# Render the existing proposal or report content here. #}
</article>

“Verified” here means the application accepted a structurally valid, evidence-based extraction tied to the requested public website. It does not establish trademark rights, licensing permission, or that remote files are safe to download.

Test success, retries, and malformed data

MockHttpClient keeps tests deterministic and prevents accidental network access. Injecting a no-op sleeper makes retry tests fast.

<?php
namespace App\Tests\BrandKit;

use App\BrandKit\BrandKitExtractor;
use App\BrandKit\BrandKitImportException;
use App\BrandKit\BrandKitMapper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class BrandKitExtractorTest extends TestCase
{
    private function payload(): string
    {
        return json_encode([
            'brand_name' => 'Example',
            'logos' => ['https://example.com/logo.svg'],
            'colors' => ['#123456'],
            'fonts' => [],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--primary-color' => '#123456'],
        ], JSON_THROW_ON_ERROR);
    }

    public function testMapsACompleteResponse(): void
    {
        $http = new MockHttpClient([
            new MockResponse($this->payload(), ['http_code' => 200]),
        ]);

        $extractor = new BrandKitExtractor(
            $http,
            'test-token',
            new BrandKitMapper(),
            static fn (int $milliseconds) => null,
        );

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

        self::assertSame('Example', $kit->brandName);
        self::assertSame(['https://example.com/logo.svg'], $kit->logoUrls);
        self::assertSame(1, $http->getRequestsCount());
    }

    public function testRetriesAServiceFailure(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse($this->payload(), ['http_code' => 200]),
        ]);

        $extractor = new BrandKitExtractor(
            $http, 'test-token', new BrandKitMapper(),
            static fn (int $milliseconds) => null,
        );

        self::assertSame('Example', $extractor->extract('https://example.com')->brandName);
        self::assertSame(2, $http->getRequestsCount());
    }

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

        $extractor = new BrandKitExtractor(
            $http, 'test-token', new BrandKitMapper(),
            static fn (int $milliseconds) => null,
        );

        $this->expectException(BrandKitImportException::class);
        $extractor->extract('https://example.com');
    }
}

Run php bin/phpunit, then test the command with a non-production customer identifier.

Deployment, monitoring, and common failures

Make var/brand-kits writable by the application user and durable across releases. In a multi-node deployment, move snapshots to shared storage. Warm application configuration after injecting BRAND_KIT_TOKEN, and restart long-running processes when rotating it.

Monitor completion and failure counts by kind, plus request duration at the HTTP-client layer. Never log tokens, full authorization headers, response bodies, or URL query strings. The command logs only the customer identifier and website host.

  • Authentication failure: confirm plan activation and the current service token. A regenerated token invalidates the previous one.
  • Invalid request: verify the URL is public, absolute, and uses HTTP or HTTPS.
  • Rate limit: let bounded retries finish, then schedule the import later or review plan capacity.
  • Invalid response: preserve the last known-good snapshot, alert on the schema failure, and compare the service documentation before changing the mapper.
  • Missing logo: accept the valid brand kit and render the proposal without a logo rather than guessing one.
  • Read-only or ephemeral filesystem: replace the repository with durable database or object storage before adding replicas.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active.
  • The current service-scoped token is supplied through BRAND_KIT_TOKEN.
  • The minimal POST request succeeds against the exact extraction endpoint.
  • All seven required brand sections are validated before persistence.
  • Authentication and validation failures are never retried blindly.
  • Rate limits, transport errors, and server failures use bounded retries.
  • The snapshot directory is writable, durable, and excluded from public web access.
  • Proposal CSS values are allow-listed and validated.
  • Remote assets are not fetched by server-side renderers without SSRF protection.
  • Tests pass without making real network requests.

The lasting improvement is not merely that a logo appears automatically. It is that branding becomes a controlled input: acquired through one explicit API boundary, checked before storage, observable when it fails, and safely adapted to the proposal renderer. That turns a repetitive design chore into a dependable part of the application rather than another collection of copied files and hopeful assumptions.

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.