Туториали

Native PHP 8.3: Automate New Client Workspace Setup with Brand Kit Extraction

Native PHP 8.3: Автоматизирајте го поставувањето работен простор за нов клиент со извлекување на бренд-комплет

A new client workspace should not begin with someone copying a hex value from a screenshot and downloading a blurry logo from a browser tab. If the client already has a public website, much of that setup can be automated.

In this tutorial, we will build a production-oriented Native PHP 8.3 command that submits a website to the Brand Kit Extractor API, validates the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables, then atomically stores the approved data in a new workspace. The integration uses native cURL, bounded retries, structured failures, deterministic tests, and no framework dependencies in production.

Get access and create a service token

Access is configured before any integration code is written:

  1. Register through the registration page, or use the sign-in page 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.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer header because query parameters are more likely to appear in access logs, browser histories, and monitoring systems.

Regenerating the service token revokes the previously active token. Treat rotation as an operational change: deploy the replacement everywhere that runs this integration, verify it, and only then retire assumptions about the old credential.

Confirm the endpoint before building the feature

The exact request is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url. Run one minimal request with a placeholder token:

curl --fail-with-body \
  --connect-timeout 3 \
  --max-time 15 \
  -X POST \
  -H "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  --data '{"url":"https://example.com"}' \
  https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit

Inspect the result without copying it blindly into application storage. The production boundary must validate the seven required areas even when their nested objects evolve.

Keep the credential outside source control. Create a local .env file, exclude it from Git, and let the deployment platform inject the same variables in production:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
WORKSPACE_DIR=/var/lib/client-workspaces

Choose a small architecture with hard boundaries

This workflow is suitable for a command invoked by an onboarding process or background worker. Brand extraction performs network I/O and can take longer than an interactive form submission, so a web application should enqueue this command rather than hold an HTTP request open.

The design has four boundaries: a transport owns cURL, a client owns authentication and retry policy, a mapper converts untrusted JSON into a domain object, and a store writes only validated brand kits. This keeps API changes away from workspace storage and makes tests independent of the network.

The project needs PHP 8.3, Composer, the cURL and JSON extensions, and PHPUnit 11 for development:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  },
  "autoload-dev": {
    "classmap": ["tests/"]
  }
}
brand-workspace/
  bin/create-workspace
  src/Http.php
  src/BrandKit.php
  src/BrandKitClient.php
  tests/BrandKitClientTest.php
  composer.json
  .env

Run composer install followed by composer dump-autoload. No runtime HTTP package is necessary because PHP's native cURL extension gives us the timeout, header, and response-size controls this integration needs.

Build a bounded native cURL transport

The transport returns status, headers, and body without interpreting the API domain. It caps the response at two megabytes, verifies TLS, and separates connection timeout from total response timeout.

<?php
// src/Http.php
namespace App;

interface Transport
{
    public function postJson(string $url, array $headers, string $body): HttpResponse;
}

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

final class TransportException extends \RuntimeException {}

final class CurlTransport implements Transport
{
    public function postJson(string $url, array $headers, string $json): HttpResponse
    {
        $handle = curl_init($url);
        if ($handle === false) {
            throw new TransportException('Unable to initialize cURL');
        }

        $body = '';
        $responseHeaders = [];
        $tooLarge = false;

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $json,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
                use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return $length;
            },
            CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk)
                use (&$body, &$tooLarge): int {
                if (strlen($body) + strlen($chunk) > 2 * 1024 * 1024) {
                    $tooLarge = true;
                    return 0;
                }
                $body .= $chunk;
                return strlen($chunk);
            },
        ]);

        $ok = curl_exec($handle);
        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        $error = curl_error($handle);

        if ($tooLarge) {
            throw new TransportException('API response exceeded 2 MiB');
        }
        if ($ok === false) {
            throw new TransportException('Transport failed: ' . $error);
        }

        return new HttpResponse($status, $responseHeaders, $body);
    }
}

Do not log the request headers or raw body. Headers contain the token, while a returned brand kit may include client information that does not belong in general application logs.

Validate the response at the application boundary

The remote document is untrusted input even when the service is trusted. Our mapper requires the documented brand areas, rejects incomplete logo, color, or font results, limits collection sizes, and permits nested scalar evidence without assuming undocumented fields inside those collections.

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

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

    public function toArray(): array
    {
        return [
            'brand_name' => $this->brandName,
            'logos' => $this->logos,
            'colors' => $this->colors,
            'fonts' => $this->fonts,
            'imagery' => $this->imagery,
            'social_profiles' => $this->socialProfiles,
            'css_variables' => $this->cssVariables,
        ];
    }
}

final class InvalidBrandKit extends \RuntimeException {}

final class BrandKitMapper
{
    public function map(array $data): BrandKit
    {
        $name = $data['brand_name'] ?? null;
        if (!is_string($name) || trim($name) === '') {
            throw new InvalidBrandKit('brand_name must be a non-empty string');
        }

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

        foreach ($fields as $field) {
            if (!array_key_exists($field, $data) || !is_array($data[$field])) {
                throw new InvalidBrandKit($field . ' must be an array');
            }
            if (count($data[$field]) > 500 || !$this->safe($data[$field], 0)) {
                throw new InvalidBrandKit($field . ' has an unsafe structure');
            }
        }

        foreach (['logos', 'colors', 'fonts'] as $required) {
            if ($data[$required] === []) {
                throw new InvalidBrandKit($required . ' is empty');
            }
        }

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

    private function safe(mixed $value, int $depth): bool
    {
        if ($depth > 8) {
            return false;
        }
        if (is_array($value)) {
            foreach ($value as $item) {
                if (!$this->safe($item, $depth + 1)) {
                    return false;
                }
            }
            return true;
        }
        return is_string($value) || is_int($value) ||
            is_float($value) || is_bool($value) || $value === null;
    }
}

Keeping nested evidence intact is deliberate. Flattening an unfamiliar logo or font object could silently discard provenance or variants. The workspace receives the validated service document; presentation-specific selectors can be added after confirming the live documentation and representative responses.

Add authentication, retries, and structured failures

Network failures, HTTP 429 responses, and server-side 5xx responses are transient candidates. Authentication failures and invalid payloads are not. The client attempts a request at most three times, honors a numeric Retry-After value up to five seconds, and otherwise applies capped exponential backoff with small jitter.

<?php
// src/BrandKitClient.php
namespace App;

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

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

    private \Closure $sleep;
    private \Closure $log;

    public function __construct(
        private Transport $http,
        private BrandKitMapper $mapper,
        private string $token,
        callable $sleep,
        callable $log,
    ) {
        $this->sleep = \Closure::fromCallable($sleep);
        $this->log = \Closure::fromCallable($log);
    }

    public function extract(string $url): BrandKit
    {
        $json = json_encode(['url' => $url], JSON_THROW_ON_ERROR);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->postJson(self::ENDPOINT, [
                    'Authorization: Bearer ' . $this->token,
                    'Content-Type: application/json',
                    'Accept: application/json',
                ], $json);
            } catch (TransportException $e) {
                ($this->log)('brand_kit_attempt_failed', [
                    'attempt' => $attempt, 'kind' => 'transport',
                ]);
                if ($attempt === 3) {
                    throw new ApiFailure('transport', $e->getMessage());
                }
                $this->pause($attempt, null);
                continue;
            }

            $transient = $response->status === 429 ||
                ($response->status >= 500 && $response->status <= 599);

            if ($transient && $attempt < 3) {
                ($this->log)('brand_kit_attempt_failed', [
                    'attempt' => $attempt,
                    'kind' => $response->status === 429 ? 'rate_limit' : 'upstream',
                    'status' => $response->status,
                ]);
                $this->pause($attempt, $response->headers['retry-after'] ?? null);
                continue;
            }

            if ($response->status < 200 || $response->status >= 300) {
                $kind = match (true) {
                    in_array($response->status, [401, 403], true) => 'authentication',
                    $response->status === 429 => 'rate_limit',
                    $response->status >= 500 => 'upstream',
                    default => 'http',
                };
                throw new ApiFailure($kind, 'Brand API returned HTTP ' . $response->status);
            }

            try {
                $decoded = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
                if (!is_array($decoded)) {
                    throw new \JsonException('Root value is not an object');
                }
                return $this->mapper->map($decoded);
            } catch (\JsonException|InvalidBrandKit $e) {
                throw new ApiFailure('invalid_response', $e->getMessage());
            }
        }

        throw new ApiFailure('internal', 'Retry loop ended unexpectedly');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $seconds = ctype_digit((string) $retryAfter)
            ? min(5, (int) $retryAfter)
            : min(5, 0.2 * (2 ** ($attempt - 1)));
        $seconds += random_int(0, 100) / 1000;
        ($this->sleep)((int) ($seconds * 1_000_000));
    }
}

Create and store the client workspace

The command should reject malformed destinations before spending quota. Accept only public HTTP or HTTPS URLs without embedded credentials, and use a restricted workspace identifier to prevent path traversal.

After loading .env into the process environment, the command constructs the client with new CurlTransport(), new BrandKitMapper(), usleep(...), and a logger that writes JSON events to standard error. It then calls extract($url).

Store the result under the configured directory as <workspace-id>/brand-kit.json. The write sequence should create the directory with restricted permissions, acquire an exclusive lock, refuse to overwrite an existing brand kit, write JSON to a temporary file in the same directory, set appropriate file permissions, and rename it into place. A same-filesystem rename makes publication atomic: readers see either the previous state or the complete new document, never a partially written file.

$document = [
    'workspace_id' => $workspaceId,
    'source_url' => $url,
    'brand_kit' => $client->extract($url)->toArray(),
];

$json = json_encode(
    $document,
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);

$target = $workspaceDirectory . '/brand-kit.json';
if (is_file($target)) {
    throw new RuntimeException('Workspace brand kit already exists');
}

$temp = tempnam($workspaceDirectory, '.brand-kit-');
if ($temp === false || file_put_contents($temp, $json, LOCK_EX) === false) {
    throw new RuntimeException('Unable to stage brand kit');
}

chmod($temp, 0640);
if (!rename($temp, $target)) {
    @unlink($temp);
    throw new RuntimeException('Unable to publish brand kit');
}

A production command should return zero only after the rename succeeds. Map validation or authentication failures to a permanent-failure exit code; map exhausted transport, rate-limit, and upstream failures to a temporary-failure exit code so an external job runner can reschedule them. Never print the token or full remote response in either path.

Test retries without touching the service

A fake transport makes timing and failure behavior deterministic. The injected sleeper prevents tests from actually waiting.

<?php
// tests/BrandKitClientTest.php
namespace App\Tests;

use App\BrandKitClient;
use App\BrandKitMapper;
use App\HttpResponse;
use App\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function postJson(string $url, array $headers, string $body): HttpResponse
    {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class BrandKitClientTest extends TestCase
{
    public function testRetriesRateLimitThenMapsBrandKit(): void
    {
        $valid = 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-primary' => '#112233'],
        ], JSON_THROW_ON_ERROR);

        $transport = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '0'], '{}'),
            new HttpResponse(200, [], $valid),
        ]);

        $client = new BrandKitClient(
            $transport,
            new BrandKitMapper(),
            'test-token',
            static fn (int $microseconds) => null,
            static fn (string $event, array $context) => null,
        );

        self::assertSame('Example', $client->extract('https://example.com')->brandName);
        self::assertSame(2, $transport->calls);
    }
}

Add companion tests for a 401 response, malformed JSON, and missing or empty required collections. The 401 test should assert exactly one transport call, proving that credentials are not retried. Run the suite with vendor/bin/phpunit tests.

Operate the integration safely

Log an internal workspace identifier, attempt number, duration, final state, failure kind, and HTTP status where available. Do not log tokens, authorization headers, full response bodies, or query-parameter credentials. Metrics should distinguish successful extraction, validation rejection, authentication failure, quota or rate limiting, transport failure, and upstream failure.

Set concurrency below the capacity of the selected plan and the rest of the onboarding pipeline. A local retry is not a substitute for queue-level scheduling: once the three bounded attempts are exhausted, let the worker reschedule with a longer delay rather than starting another immediate retry loop.

In deployment, inject BRAND_KIT_TOKEN through the platform's secret manager, mount WORKSPACE_DIR on persistent storage, grant the worker write access only to that directory, and ensure outbound HTTPS access to ai.mihajlo.mk. Rotate the token through a controlled configuration deployment because regeneration invalidates the former active token.

Common failures

  • 401 or 403: the token is absent, revoked, copied incorrectly, or not valid for this service. Do not retry automatically.
  • 429: quota or request rate has been reached. Honor Retry-After when present and reduce worker concurrency.
  • 5xx or transport failure: retry only within the bounded policy, then reschedule externally.
  • Invalid response: keep the workspace unchanged and investigate a contract change or incomplete extraction.
  • Empty logos, colors, or fonts: route the workspace to manual review instead of storing a misleading “complete” kit.
  • Existing destination: treat it as an idempotency conflict; do not silently replace a curated workspace.

Final verification checklist

  • The active plan is enabled and the service-scoped token comes from the documentation page's Service token panel.
  • The request uses the exact POST endpoint, a JSON url, and environment-backed Bearer authentication.
  • Connection time, total response time, response size, retry count, and backoff are bounded.
  • Authentication and validation failures are never retried blindly.
  • All seven response areas are validated before storage.
  • The workspace is published atomically and existing work is not overwritten.
  • Tests cover success, rate limiting, authentication failure, malformed JSON, and incomplete brand data.
  • Logs expose useful states without exposing credentials or raw client data.

The important result is not merely an API call. It is a trustworthy onboarding boundary: a client supplies a public website, the workspace receives usable logos, colors, and fonts, and uncertain data fails visibly before it can become part of the product. That is what turns convenient automation into dependable production software.

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

Mihajlo

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