Native PHP: Автоматски создавајте сосема нови работни простори за клиенти со лого, бои и фонтови
A new client workspace should feel familiar before anyone starts configuring it. If the customer has already invested in a recognizable website, asking them to upload the same logo, identify their colors, and type their fonts is needless friction. A better onboarding flow extracts that public identity, validates it, and prefills the workspace automatically.
This tutorial builds that flow in Native PHP 8.3. A command receives a workspace ID and public website URL, calls the Brand Kit Extractor API, maps the response into a strict domain object, and stores the result transactionally. The implementation uses native cURL, bounded retries, structured errors, deterministic PHPUnit tests, and no framework-specific machinery.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Brand Kit Extractor service page. Choose the available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation, locate the Service token panel, and copy the service-scoped token.
- Store that token in environment-backed configuration. Regenerating it revokes the previously active token, so rotate the deployed secret at the same time.
This service is not tokenless. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because query parameters are more likely to appear in proxy, browser, and access logs.
The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Test access with a public site you are authorized to process:
export BRAND_KIT_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body \
--connect-timeout 3 \
--max-time 20 \
-X POST \
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
-H "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data '{"url":"https://example.com"}'
Create an uncommitted .env file before building the feature:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_DSN=sqlite:/var/lib/workspaces/app.sqlite
Commit only a token-free .env.example. Production should inject the same variables through its secret manager or deployment platform rather than shipping a populated file.
Architecture: a narrow boundary around uncertain data
The API provides evidence-based brand data: a brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Those categories belong in our domain, but their nested evidence may evolve. The mapper therefore enforces the required top-level contract while preserving nested arrays instead of guessing at undocumented logo or font subfields.
The flow has four small parts:
CurlTransportowns HTTP mechanics and can be replaced in tests.BrandKitExtractorhandles authentication, retry policy, JSON decoding, and response mapping.BrandKitrejects incomplete or unsafe-to-store payloads.WorkspaceBrandServiceverifies the workspace and persists the complete snapshot in one transaction.
Synchronous extraction keeps the example operationally simple. If website analysis becomes too slow for an interactive signup request, invoke the same service from an existing worker. Do not duplicate its HTTP or validation logic inside a queue consumer.
Project structure and dependencies
brand-prefill/
├── .env
├── .env.example
├── composer.json
├── bin/prefill-workspace.php
├── database/schema.sql
├── src/Brand/BrandKit.php
├── src/Brand/BrandKitExtractor.php
├── src/Http/CurlTransport.php
├── src/Http/HttpResponse.php
├── src/Http/Transport.php
├── src/Workspace/WorkspaceBrandService.php
└── tests/BrandKitExtractorTest.php
PHP 8.3, the cURL extension, PDO, Composer, and a PDO driver for your database are prerequisites. Use vlucas/phpdotenv 5.6 for local environment loading and PHPUnit 11 for tests:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
Run composer install, then add .env to .gitignore.
Build the HTTP boundary
The transport returns status, body, and response headers without knowing anything about brands. That separation makes retries testable without opening a network connection.
<?php
// src/Http/Transport.php
namespace App\Http;
interface Transport
{
public function postJson(
string $url,
array $headers,
string $body,
int $connectTimeout,
int $timeout
): HttpResponse;
}
// src/Http/HttpResponse.php
namespace App\Http;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers = []
) {}
}
// src/Http/CurlTransport.php
namespace App\Http;
use RuntimeException;
final class CurlTransport implements Transport
{
public function postJson(
string $url,
array $headers,
string $body,
int $connectTimeout,
int $timeout
): HttpResponse {
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('Unable to initialize cURL');
}
$responseHeaders = [];
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HEADERFUNCTION => static function (
$handle,
string $line
) use (&$responseHeaders): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
try {
$body = curl_exec($handle);
if ($body === false) {
throw new RuntimeException('Brand API network failure: ' . curl_error($handle));
}
return new HttpResponse(
curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$body,
$responseHeaders
);
} finally {
curl_close($handle);
}
}
}
Map and validate the brand kit
Validation happens before storage. A successful HTTP status is insufficient: an HTML proxy page, truncated JSON document, or partial response must never become a workspace theme.
<?php
// src/Brand/BrandKit.php
namespace App\Brand;
use InvalidArgumentException;
use JsonException;
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 static function fromApi(array $data): self
{
$name = $data['brand_name'] ?? null;
if (!is_string($name) || trim($name) === '' || strlen($name) > 300) {
throw new InvalidArgumentException('Invalid brand_name');
}
$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 InvalidArgumentException("Invalid or missing {$field}");
}
}
try {
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new InvalidArgumentException('Brand kit is not JSON-safe', 0, $exception);
}
if (strlen($encoded) > 2_000_000) {
throw new InvalidArgumentException('Brand kit exceeds the storage limit');
}
return new self(
trim($name),
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$data['css_variables']
);
}
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,
];
}
}
The client retries network exceptions, HTTP 429, and server failures. It does not retry authentication or validation failures: repeating an invalid request only consumes time and quota. Backoff is capped, as is a numeric Retry-After value.
<?php
// src/Brand/BrandKitExtractor.php
namespace App\Brand;
use App\Http\Transport;
use Closure;
use JsonException;
use RuntimeException;
final class BrandKitExtractor
{
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 $transport,
private string $token,
?Closure $sleep = null,
?Closure $log = null
) {
if (trim($token) === '') {
throw new RuntimeException('BRAND_KIT_TOKEN is missing');
}
$this->sleep = $sleep ?? static fn(int $microseconds) => usleep($microseconds);
$this->log = $log ?? static fn(array $context) =>
error_log(json_encode($context, JSON_UNESCAPED_SLASHES));
}
public function extract(string $url): BrandKit
{
$this->assertPublicUrl($url);
$body = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
for ($attempt = 1; $attempt <= 3; $attempt++) {
$started = hrtime(true);
try {
$response = $this->transport->postJson(
self::ENDPOINT,
[
'Authorization: Bearer ' . $this->token,
'Accept: application/json',
'Content-Type: application/json',
],
$body,
3,
20
);
} catch (RuntimeException $exception) {
($this->log)([
'event' => 'brand_kit_request',
'attempt' => $attempt,
'outcome' => 'network_error',
]);
if ($attempt === 3) {
throw $exception;
}
($this->sleep)(250_000 * (2 ** ($attempt - 1)));
continue;
}
($this->log)([
'event' => 'brand_kit_request',
'attempt' => $attempt,
'status' => $response->status,
'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
]);
if ($response->status >= 200 && $response->status < 300) {
try {
$data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Brand API returned invalid JSON', 0, $exception);
}
if (!is_array($data)) {
throw new RuntimeException('Brand API returned an invalid document');
}
return BrandKit::fromApi($data);
}
if (in_array($response->status, [401, 403], true)) {
throw new RuntimeException('Brand API authentication failed');
}
$retryable = $response->status === 429 || $response->status >= 500;
if (!$retryable || $attempt === 3) {
throw new RuntimeException(
"Brand API request failed with HTTP {$response->status}"
);
}
$retryAfter = $response->headers['retry-after'] ?? null;
$delay = ctype_digit((string) $retryAfter)
? min((int) $retryAfter * 1_000_000, 2_000_000)
: 250_000 * (2 ** ($attempt - 1));
($this->sleep)($delay);
}
throw new RuntimeException('Brand API retry loop ended unexpectedly');
}
private function assertPublicUrl(string $url): void
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = $parts['host'] ?? '';
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
throw new RuntimeException('A public HTTP or HTTPS URL is required');
}
if (filter_var($host, FILTER_VALIDATE_IP) !== false &&
filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) === false) {
throw new RuntimeException('Private or reserved IP addresses are not allowed');
}
}
}
Persist the snapshot with the workspace
Store the normalized response as one JSON snapshot. That preserves the relationship between selected logos, colors, fonts, imagery, social evidence, and generated CSS variables. Product-specific columns can be derived later without throwing evidence away.
CREATE TABLE workspaces (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE workspace_brand_profiles (
workspace_id VARCHAR(64) PRIMARY KEY,
brand_name VARCHAR(300) NOT NULL,
brand_kit_json TEXT NOT NULL,
source_url TEXT NOT NULL,
updated_at VARCHAR(32) NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
);
<?php
// src/Workspace/WorkspaceBrandService.php
namespace App\Workspace;
use App\Brand\BrandKitExtractor;
use PDO;
use RuntimeException;
final readonly class WorkspaceBrandService
{
public function __construct(
private PDO $pdo,
private BrandKitExtractor $extractor
) {}
public function prefill(string $workspaceId, string $sourceUrl): void
{
$kit = $this->extractor->extract($sourceUrl);
$this->pdo->beginTransaction();
try {
$check = $this->pdo->prepare(
'SELECT 1 FROM workspaces WHERE id = :id'
);
$check->execute(['id' => $workspaceId]);
if ($check->fetchColumn() === false) {
throw new RuntimeException('Workspace does not exist');
}
$statement = $this->pdo->prepare(
'INSERT INTO workspace_brand_profiles
(workspace_id, brand_name, brand_kit_json, source_url, updated_at)
VALUES (:id, :name, :kit, :url, :updated)
ON CONFLICT(workspace_id) DO UPDATE SET
brand_name = excluded.brand_name,
brand_kit_json = excluded.brand_kit_json,
source_url = excluded.source_url,
updated_at = excluded.updated_at'
);
$statement->execute([
'id' => $workspaceId,
'name' => $kit->brandName,
'kit' => json_encode($kit->toArray(), JSON_THROW_ON_ERROR),
'url' => $sourceUrl,
'updated' => gmdate(DATE_ATOM),
]);
$this->pdo->commit();
} catch (\Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
}
The upsert syntax above targets SQLite. For another database, keep the service contract and replace only the database-specific upsert statement.
Wire the command
<?php
// bin/prefill-workspace.php
use App\Brand\BrandKitExtractor;
use App\Http\CurlTransport;
use App\Workspace\WorkspaceBrandService;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
[$script, $workspaceId, $sourceUrl] = array_pad($argv, 3, null);
if (!$workspaceId || !$sourceUrl) {
fwrite(STDERR, "Usage: php bin/prefill-workspace.php WORKSPACE_ID PUBLIC_URL\n");
exit(2);
}
try {
$pdo = new PDO(
$_ENV['DATABASE_DSN'] ?? '',
null,
null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$extractor = new BrandKitExtractor(
new CurlTransport(),
$_ENV['BRAND_KIT_TOKEN'] ?? ''
);
(new WorkspaceBrandService($pdo, $extractor))
->prefill($workspaceId, $sourceUrl);
fwrite(STDOUT, "Workspace brand profile updated.\n");
} catch (Throwable $exception) {
error_log(json_encode([
'event' => 'workspace_brand_prefill_failed',
'workspace_id' => $workspaceId,
'error_type' => $exception::class,
'message' => $exception->getMessage(),
]));
exit(1);
}
After creating a workspace, run php bin/prefill-workspace.php ws_123 https://example.com. The token is never passed on the command line or written to logs.
Test without calling the service
A fake transport makes success, throttling, and malformed responses deterministic. The retry test also injects a no-op sleeper, so the suite remains fast.
<?php
// tests/BrandKitExtractorTest.php
namespace Tests;
use App\Brand\BrandKitExtractor;
use App\Http\HttpResponse;
use App\Http\Transport;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class BrandKitExtractorTest extends TestCase
{
public function testRetriesThrottleThenMapsCompleteKit(): void
{
$transport = new FakeTransport([
new HttpResponse(429, '{}', ['retry-after' => '1']),
new HttpResponse(200, json_encode([
'brand_name' => 'Example',
'logos' => [['url' => 'https://example.com/logo.svg']],
'colors' => ['#112233'],
'fonts' => ['Example Sans'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#112233'],
], JSON_THROW_ON_ERROR)),
]);
$kit = (new BrandKitExtractor(
$transport,
'test-token',
static fn(int $microseconds) => null,
static fn(array $context) => null
))->extract('https://example.com');
self::assertSame('Example', $kit->brandName);
self::assertCount(2, $transport->requests);
}
public function testRejectsIncompleteSuccessfulResponse(): void
{
$this->expectException(\InvalidArgumentException::class);
$client = new BrandKitExtractor(
new FakeTransport([
new HttpResponse(200, '{"brand_name":"Incomplete"}'),
]),
'test-token',
static fn(int $microseconds) => null,
static fn(array $context) => null
);
$client->extract('https://example.com');
}
}
final class FakeTransport implements Transport
{
public array $requests = [];
public function __construct(private array $responses) {}
public function postJson(
string $url,
array $headers,
string $body,
int $connectTimeout,
int $timeout
): HttpResponse {
$this->requests[] = compact('url', 'headers', 'body');
return array_shift($this->responses)
?? throw new RuntimeException('No fake response queued');
}
}
Run the suite with vendor/bin/phpunit tests. Add repository tests against a temporary SQLite database to verify rollback, unknown workspace handling, and repeated upserts.
Production safeguards and common failures
- Authentication failures: HTTP 401 or 403 usually means a missing, revoked, or wrongly scoped token. Replace the deployed secret after regeneration; do not retry it.
- Quota or throttling: HTTP 429 is retried only within a small bound. If all attempts fail, preserve the unbranded workspace and expose a retry action instead of blocking account creation indefinitely.
- Invalid input: Reject malformed URLs and private IP literals before sending them. Apply authorization separately so one customer cannot modify another customer’s workspace.
- Partial responses: Treat missing brand categories as a contract failure. Never silently convert an incomplete result into an apparently valid theme.
- Logging: Record outcome, attempt, HTTP status, duration, workspace ID, and error type. Do not log authorization headers, response bodies, or service tokens.
- Presentation safety: Treat extracted URLs and text as untrusted data. Escape text in HTML, validate image URLs at render time, and never inject returned CSS directly into a page without an application-level allowlist.
At deployment, install dependencies with optimized autoloading, apply the schema migration before enabling the command, inject BRAND_KIT_TOKEN and DATABASE_DSN, and confirm the PHP runtime has cURL and the required PDO driver. Alert on sustained authentication failures, throttling, server errors, and elevated latency rather than on a single transient retry.
Final verification checklist
- The account and selected plan are active, and the service-scoped token comes from the documentation page’s Service token panel.
- The minimal POST request succeeds against the exact extraction endpoint.
- No credential appears in source control, command arguments, fixtures, or logs.
- A known workspace receives a validated brand name, logos, colors, fonts, imagery, social profiles, and CSS variables.
- An unknown workspace rolls back without creating an orphaned profile.
- HTTP 401 and 403 fail immediately; HTTP 429 and server errors retry only within the configured bound.
- The PHPUnit suite passes without network access.
- The workspace UI escapes extracted content and applies only explicitly approved visual values.
The valuable part of this integration is not merely discovering a logo. It is turning public brand evidence into a trustworthy application state: authenticated at the edge, constrained under failure, validated before persistence, and safe to revise. Done well, the first view of a new workspace no longer feels empty. It already feels like it belongs to the client.