Tutorials

Symfony: Automate Proposals with Verified Brand Assets via API

Symfony: Automate Proposals with Verified Brand Assets via API

A proposal can be technically correct and still feel unfinished when the client’s logo, colors, typography, and visual language are missing. Copying those assets by hand is slow, inconsistent, and surprisingly easy to get wrong. A better workflow imports a website’s evidence-based brand data once, validates it at the application boundary, and gives every proposal or recurring report the same controlled snapshot.

This tutorial builds that workflow in Symfony and PHP 8.3. A console command calls the Brand Kit Extractor API, maps its response into a domain object, and stores an atomic JSON snapshot. Proposal and report generation then reads the cached snapshot instead of calling an external service during a customer-facing request.

Get access and copy the service token

Start by creating an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.

  1. Open the Brand Kit Extractor service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store it in environment-backed configuration. Never commit it to the repository.

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

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed environment promptly, verify a request with the new token, and remove any obsolete secret version.

Confirm the exact API call

The extraction operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. It accepts JSON containing url. Test the credential before writing integration code:

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 that 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 a successful HTTP status makes every value safe or suitable for publication; the Symfony boundary will validate the structure before storage.

Place the token in .env.local, which Symfony projects normally exclude from version control:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Architecture: import once, render many times

Calling extraction while someone waits for a proposal preview would couple page availability to remote latency, quotas, and transient failures. This design instead uses a deliberate import command. The generator consumes the last valid local snapshot, so an upstream outage cannot break an already configured client’s proposal.

  • BrandKitExtractor owns HTTP authentication, timeouts, retries, and status handling.
  • BrandKit validates and maps remote data into the application domain.
  • BrandKitStore writes an atomic snapshot keyed by a local client identifier.
  • ImportBrandKitCommand performs controlled imports during onboarding or refreshes.
  • Proposal and report code reads only validated snapshots.

The extractor provides evidence-based data found on a public site. “Verified” here means structurally validated and traceable to the requested URL, not proof of trademark ownership or permission to use every discovered asset. Keep human approval in the publishing workflow.

Create the Symfony project

You need PHP 8.3 or later, Composer, and outbound HTTPS access from the runtime. Create a focused Symfony application and install first-party components:

composer create-project symfony/skeleton proposal-branding
cd proposal-branding
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack

Create src/Brand for the integration and var/brand-kits for generated snapshots. The latter must be writable in production and persistent across releases.

Bind the token in config/services.yaml. Symfony’s default service discovery can autowire the remaining dependencies:

parameters:
    brand_kit.storage_dir: '%kernel.project_dir%/var/brand-kits'

services:
    _defaults:
        autowire: true
        autoconfigure: true

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

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

    App\Brand\BrandKitStore:
        arguments:
            $directory: '%brand_kit.storage_dir%'

Map the response into a strict domain object

Keep knowledge of external field names in one class. The array fields may contain strings, objects, or nested evidence depending on the discovered site, so the boundary requires JSON-compatible arrays without inventing a narrower undocumented schema.

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

final readonly class BrandKit 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 string $sourceUrl,
        public string $importedAt,
    ) {}

    public static function fromApi(array $data, string $sourceUrl): self
    {
        $brandName = $data['brand_name'] ?? null;

        if (!is_string($brandName) || trim($brandName) === '') {
            throw new \UnexpectedValueException('Missing or invalid brand_name.');
        }

        $arrays = [];
        foreach ([
            'logos',
            'colors',
            'fonts',
            'imagery',
            'social_profiles',
            'css_variables',
        ] as $field) {
            if (!array_key_exists($field, $data) || !is_array($data[$field])) {
                throw new \UnexpectedValueException(
                    sprintf('Missing or invalid %s.', $field)
                );
            }

            self::assertJsonValue($data[$field], $field);
            $arrays[$field] = $data[$field];
        }

        return new self(
            trim($brandName),
            $arrays['logos'],
            $arrays['colors'],
            $arrays['fonts'],
            $arrays['imagery'],
            $arrays['social_profiles'],
            $arrays['css_variables'],
            $sourceUrl,
            (new \DateTimeImmutable())->format(DATE_ATOM),
        );
    }

    private static function assertJsonValue(mixed $value, string $path): void
    {
        if (is_array($value)) {
            foreach ($value as $key => $child) {
                self::assertJsonValue($child, $path.'.'.$key);
            }
            return;
        }

        if (!is_null($value) && !is_scalar($value)) {
            throw new \UnexpectedValueException('Invalid value at '.$path);
        }
    }

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

If the official documentation changes field spelling or adds a response envelope, update this mapper and its tests rather than spreading conditional parsing through controllers and templates.

Build a bounded, retry-aware HTTP client

The client retries only failures likely to be temporary: transport errors, 429, and server-side 5xx responses. Authentication and request-validation failures are returned immediately because retrying the same input and credential would waste quota and delay diagnosis.

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

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

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

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

    public function extract(string $url): BrandKit
    {
        $this->assertPublicHttpUrl($url);
        $lastError = null;

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'auth_bearer' => $this->token,
                    'json' => ['url' => $url],
                    'timeout' => 10.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    return BrandKit::fromApi($response->toArray(false), $url);
                }

                if (in_array($status, [400, 401, 403, 422], true)) {
                    throw new BrandKitImportFailed(
                        'Remote request rejected.',
                        $status,
                        false
                    );
                }

                if ($status !== 429 && $status < 500) {
                    throw new BrandKitImportFailed(
                        'Unexpected remote response.',
                        $status,
                        false
                    );
                }

                $lastError = new BrandKitImportFailed(
                    $status === 429
                        ? 'Rate limit or quota reached.'
                        : 'Remote service is temporarily unavailable.',
                    $status,
                    true
                );
            } catch (TransportExceptionInterface $error) {
                $lastError = new BrandKitImportFailed(
                    'Transport failure while importing the brand kit.',
                    0,
                    true,
                    $error
                );
            }

            $this->logger->warning('Brand-kit import attempt failed', [
                'url' => $url,
                'attempt' => $attempt,
                'retryable' => $lastError->retryable,
                'status' => $lastError->getCode(),
            ]);

            if (!$lastError->retryable || $attempt === 3) {
                throw $lastError;
            }

            usleep((250 * (2 ** ($attempt - 1)) + random_int(0, 100)) * 1000);
        }

        throw $lastError;
    }

    private function assertPublicHttpUrl(string $url): void
    {
        $parts = parse_url($url);

        if (
            !is_array($parts)
            || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
            || empty($parts['host'])
        ) {
            throw new \InvalidArgumentException(
                'A public HTTP or HTTPS URL is required.'
            );
        }
    }
}
<?php
// src/Brand/BrandKitImportFailed.php
namespace App\Brand;

final class BrandKitImportFailed extends \RuntimeException
{
    public function __construct(
        string $message,
        int $status,
        public readonly bool $retryable,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, $status, $previous);
    }
}

The logs deliberately omit the token and response body. If arbitrary users can submit URLs, add an application allowlist or ownership verification. Scheme validation prevents malformed input, but it does not establish that the requester controls the remote domain.

Store only complete snapshots

An interrupted write must not replace a working brand kit with half a JSON document. Write to a temporary file in the destination directory and rename it atomically:

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

final class BrandKitStore
{
    public function __construct(private string $directory) {}

    public function save(string $client, BrandKit $kit): void
    {
        if (!preg_match('/\A[a-z0-9][a-z0-9-]{1,63}\z/', $client)) {
            throw new \InvalidArgumentException('Invalid client identifier.');
        }

        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0770, true)
            && !is_dir($this->directory)) {
            throw new \RuntimeException('Cannot create brand-kit directory.');
        }

        $target = $this->directory.'/'.$client.'.json';
        $temporary = tempnam($this->directory, 'brand-kit-');

        if ($temporary === false) {
            throw new \RuntimeException('Cannot create temporary snapshot.');
        }

        try {
            $json = json_encode(
                $kit,
                JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
            );

            if (file_put_contents($temporary, $json, LOCK_EX) === false
                || !rename($temporary, $target)) {
                throw new \RuntimeException('Cannot publish brand-kit snapshot.');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }
    }

    public function get(string $client): BrandKit
    {
        $data = json_decode(
            file_get_contents($this->directory.'/'.$client.'.json'),
            true,
            512,
            JSON_THROW_ON_ERROR
        );

        return new BrandKit(
            $data['brandName'],
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['socialProfiles'],
            $data['cssVariables'],
            $data['sourceUrl'],
            $data['importedAt'],
        );
    }
}

Expose the import as an operational command

<?php
// src/Command/ImportBrandKitCommand.php
namespace App\Command;

use App\Brand\BrandKitExtractor;
use App\Brand\BrandKitImportFailed;
use App\Brand\BrandKitStore;
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 BrandKitExtractor $extractor,
        private BrandKitStore $store,
    ) {
        parent::__construct();
    }

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

    protected function execute(
        InputInterface $input,
        OutputInterface $output
    ): int {
        try {
            $client = (string) $input->getArgument('client');
            $kit = $this->extractor->extract(
                (string) $input->getArgument('url')
            );
            $this->store->save($client, $kit);
            $output->writeln('Imported brand kit for '.$kit->brandName);

            return Command::SUCCESS;
        } catch (BrandKitImportFailed | \InvalidArgumentException $error) {
            $output->writeln('<error>'.$error->getMessage().'</error>');
            return Command::FAILURE;
        }
    }
}

Import a client with php bin/console app:brand-kit:import acme https://www.example.com. The proposal generator can inject BrandKitStore, call get('acme'), and pass the resulting logos, colors, fonts, imagery, social profiles, and CSS variables into its document-rendering layer.

Do not insert remote CSS variables or asset URLs directly into HTML. Select approved entries, validate URLs again at rendering time, escape text, and constrain CSS values to formats your templates support.

Test the external boundary without network access

MockHttpClient makes success and failure behavior deterministic:

<?php
// tests/Brand/BrandKitExtractorTest.php
namespace App\Tests\Brand;

use App\Brand\BrandKitExtractor;
use App\Brand\BrandKitImportFailed;
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 testMapsACompleteResponse(): void
    {
        $response = 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]);

        $extractor = new BrandKitExtractor(
            new MockHttpClient($response),
            new NullLogger(),
            'test-token'
        );

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

        self::assertSame('Example', $kit->brandName);
        self::assertSame(['#112233'], $kit->colors);
    }

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

        $extractor = new BrandKitExtractor(
            $client,
            new NullLogger(),
            'invalid-token'
        );

        try {
            $extractor->extract('https://example.com');
            self::fail('Expected import failure.');
        } catch (BrandKitImportFailed $error) {
            self::assertFalse($error->retryable);
            self::assertSame(401, $error->getCode());
            self::assertSame(1, $calls);
        }
    }
}

Run php bin/phpunit. Add mapper tests for missing fields, malformed arrays, and invalid JSON before changing the response adapter.

Deployment, monitoring, and common failures

Inject BRAND_KIT_TOKEN through your hosting platform’s secret manager. Create the snapshot directory during deployment, grant it to the PHP user, and place it on persistent storage when releases use disposable containers. Warm deployments should preserve the last valid snapshot.

Monitor import duration, outcome, HTTP status, retry count, client identifier, and source host. Alert on sustained 401 or 403 responses because they usually indicate a revoked or incorrectly deployed token. Treat persistent 429 responses as quota or scheduling signals, not as permission for unbounded retries.

  • 401 or 403: verify the service-scoped token and whether it was regenerated.
  • 400 or 422: inspect the submitted public URL; do not retry unchanged input.
  • 429: stop after the bounded attempts, retain the previous snapshot, and retry later.
  • 5xx or transport timeout: allow the short backoff sequence, then fail without replacing stored data.
  • Mapping failure: compare the live documented response with the centralized mapper and update tests first.
  • Unwritable storage: correct deployment ownership or mount persistence; do not fall back to an unsafe public directory.

Final verification checklist

  • The exact POST endpoint succeeds with a non-production test URL.
  • The token exists only in environment-backed secret configuration.
  • All seven brand-data categories are validated before storage.
  • Authentication and validation failures are never blindly retried.
  • Rate limits, server failures, and transport errors have bounded retries.
  • Logs contain operational context but no credential or raw response body.
  • Snapshot replacement is atomic and the storage survives deployment.
  • Automated tests pass without making a network request.
  • The generated proposal uses an approved local snapshot and escapes output.

The important production decision is not merely how to call an API. It is where to place trust. By separating extraction, validation, storage, approval, and rendering, the proposal generator gains consistent branding without making every document dependent on a live external request. The result is quieter infrastructure, safer templates, and proposals that look deliberately prepared rather than assembled at the last minute.

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.