Туториали

Laravel: Automatically Style New Client Workspaces with Brand Kit Extractor API

Laravel: Автоматски стилизирајте нови клиентски работни простори со Brand Kit Extractor API

A blank client workspace creates immediate friction: someone must find the correct logo, copy brand colors, identify fonts, and translate all of that into usable settings. That administrative work is small, repetitive, and surprisingly easy to get wrong.

This tutorial turns onboarding into a reliable Laravel workflow. A user submits a public website URL, Laravel creates the workspace immediately, and a queued job calls the Brand Kit Extractor API to prefill its logo, colors, fonts, imagery, social profiles, and CSS variables. The integration uses strict boundary validation, bounded retries, structured failure states, deterministic tests, and environment-backed credentials.

Get access before writing integration code

First, register an account, or sign in if you already have one.

Open the Brand Kit Extractor service page. Choose the available Free, Plus, or Pro plan and complete its activation. Then open the official service documentation, find the Service token panel, and copy the service-scoped token.

Regenerating that token revokes the previously active token. Treat rotation as a deployment change: update every environment that uses the old value before relying on the new credential.

This service requires authentication. There is no unauthenticated mode in the supplied contract. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps the credential out of URLs, access logs, and browser history.

Confirm the endpoint with a minimal request

The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url:

curl --request POST \
  --url 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"}'

Use a public site you are permitted to process. Never paste the real token into source files, issue trackers, screenshots, shell history shared with teammates, or test fixtures.

Store it in Laravel’s environment configuration:

# .env
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT=3
BRAND_KIT_TIMEOUT=12
<?php
// config/services.php

return [
    // Other services...

    'brand_kit' => [
        'token' => env('BRAND_KIT_TOKEN'),
        'endpoint' => 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        'connect_timeout' => (int) env('BRAND_KIT_CONNECT_TIMEOUT', 3),
        'timeout' => (int) env('BRAND_KIT_TIMEOUT', 12),
    ],
];

Do not call env() from application classes. Reading through config() keeps the integration compatible with Laravel’s configuration cache.

Architecture that keeps onboarding responsive

A remote extraction may involve crawling and analysis, so an HTTP controller should not hold the user’s request open. The controller creates a workspace in pending state and dispatches an ExtractWorkspaceBrandKit job. The job calls a dedicated client, maps the untrusted response into a domain object, and atomically marks the workspace ready or failed.

The relevant project structure is deliberately small:

  • app/Http/Controllers/WorkspaceController.php accepts onboarding requests.
  • app/Jobs/ExtractWorkspaceBrandKit.php owns background execution.
  • app/Services/BrandKitClient.php owns HTTP and retry policy.
  • app/Data/BrandKit.php validates the application boundary.
  • app/Models/Workspace.php persists extraction state and brand data.
  • tests/Feature/WorkspaceBrandKitTest.php verifies the integration without network access.

This is enough separation to test failures without introducing an unnecessary repository layer or third-party SDK.

Persist explicit states and validated brand data

Create the model, migration, controller, and queued job:

php artisan make:model Workspace -m
php artisan make:controller WorkspaceController
php artisan make:job ExtractWorkspaceBrandKit
php artisan make:test WorkspaceBrandKitTest

Use JSON columns because logos, color palettes, font findings, and evidence may contain multiple structured values. Keep the state and safe failure reason separately queryable.

<?php
// database/migrations/..._create_workspaces_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('workspaces', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('website_url', 2048);
            $table->string('brand_status', 20)->default('pending');
            $table->string('brand_name')->nullable();
            $table->json('logos')->nullable();
            $table->json('colors')->nullable();
            $table->json('fonts')->nullable();
            $table->json('imagery')->nullable();
            $table->json('social_profiles')->nullable();
            $table->json('css_variables')->nullable();
            $table->string('brand_failure')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('workspaces');
    }
};

Add the JSON attributes to the model’s casts and allow only the controller’s creation fields to be mass assigned:

<?php
// app/Models/Workspace.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class Workspace extends Model
{
    protected $fillable = ['name', 'website_url', 'brand_status'];

    protected function casts(): array
    {
        return [
            'logos' => 'array',
            'colors' => 'array',
            'fonts' => 'array',
            'imagery' => 'array',
            'social_profiles' => 'array',
            'css_variables' => 'array',
        ];
    }
}

Validate the response at the API boundary

The external payload is untrusted, even after a successful HTTP response. The mapper below requires all contracted categories before anything reaches storage. It normalizes camel case, snake case, kebab case, and human-readable key labels without assuming that the fields live at a particular nesting level.

<?php
// app/Data/BrandKit.php

namespace App\Data;

use UnexpectedValueException;

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 $payload): self
    {
        $brandName = self::find($payload, 'brand name');

        if (! is_string($brandName) || trim($brandName) === '' ||
            mb_strlen($brandName) > 255) {
            throw new UnexpectedValueException('Invalid brand name.');
        }

        $collections = [];
        foreach ([
            'logos', 'colors', 'fonts', 'imagery',
            'social profiles', 'css variables',
        ] as $label) {
            $value = self::find($payload, $label);

            if (! is_array($value) || json_encode($value) === false) {
                throw new UnexpectedValueException(
                    "Invalid or missing {$label}."
                );
            }

            $collections[$label] = $value;
        }

        return new self(
            trim($brandName),
            $collections['logos'],
            $collections['colors'],
            $collections['fonts'],
            $collections['imagery'],
            $collections['social profiles'],
            $collections['css variables'],
        );
    }

    private static function find(array $node, string $wanted): mixed
    {
        $normalize = static fn (string $key): string =>
            strtolower(preg_replace('/[^a-z0-9]+/i', '', $key));

        foreach ($node as $key => $value) {
            if (is_string($key) && $normalize($key) === $normalize($wanted)) {
                return $value;
            }
        }

        foreach ($node as $value) {
            if (is_array($value)) {
                $found = self::find($value, $wanted);
                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }

    public function columns(): 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,
        ];
    }
}

Empty collections remain valid: a public site may genuinely expose no social profile or identifiable font. Missing categories, malformed types, oversized responses, and blank brand names are failures rather than guesses.

Build a bounded, status-aware HTTP client

The client retries connection failures, rate limits, and selected server failures. It does not retry authentication failures or invalid requests, because another identical request cannot repair either condition.

<?php
// app/Services/BrandKitClient.php

namespace App\Services;

use App\Data\BrandKit;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class BrandKitClient
{
    public function extract(string $url): BrandKit
    {
        $token = config('services.brand_kit.token');

        if (! is_string($token) || $token === '') {
            throw new RuntimeException('brand_kit_configuration');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->acceptJson()
                    ->asJson()
                    ->connectTimeout(config('services.brand_kit.connect_timeout'))
                    ->timeout(config('services.brand_kit.timeout'))
                    ->post(config('services.brand_kit.endpoint'), ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'brand_kit_unavailable',
                        previous: $exception
                    );
                }

                usleep($attempt * 250_000);
                continue;
            }

            if ($response->successful()) {
                if (strlen($response->body()) > 1_000_000) {
                    throw new RuntimeException('brand_kit_response_too_large');
                }

                $payload = $response->json();

                if (! is_array($payload)) {
                    throw new RuntimeException('brand_kit_invalid_json');
                }

                return BrandKit::fromApi($payload);
            }

            if (in_array($response->status(), [401, 403], true)) {
                throw new RuntimeException('brand_kit_authentication');
            }

            if (in_array($response->status(), [400, 422], true)) {
                throw new RuntimeException('brand_kit_request_rejected');
            }

            $retryable = $response->status() === 429 ||
                in_array($response->status(), [500, 502, 503, 504], true);

            if (! $retryable || $attempt === 3) {
                throw new RuntimeException('brand_kit_upstream_failure');
            }

            $retryAfter = ctype_digit($response->header('Retry-After', ''))
                ? (int) $response->header('Retry-After')
                : 0;

            $delayMs = $retryAfter > 0
                ? min($retryAfter * 1000, 2000)
                : $attempt * 250;

            usleep($delayMs * 1000);
        }

        throw new RuntimeException('brand_kit_unavailable');
    }
}

The delays are intentionally capped. A queue worker should yield a structured failure rather than sleep indefinitely because an upstream service supplied a large Retry-After value.

Connect the controller and queue job

Only accept public HTTP or HTTPS hostnames. Reject localhost and IP literals; the feature is intended for public brand websites, not internal discovery.

<?php
// app/Http/Controllers/WorkspaceController.php

namespace App\Http\Controllers;

use App\Jobs\ExtractWorkspaceBrandKit;
use App\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class WorkspaceController
{
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'website_url' => [
                'required', 'url:http,https', 'max:2048',
                function (string $attribute, mixed $value, $fail): void {
                    $host = parse_url((string) $value, PHP_URL_HOST);

                    if (! is_string($host) || $host === 'localhost' ||
                        filter_var($host, FILTER_VALIDATE_IP)) {
                        $fail('A public hostname is required.');
                    }
                },
            ],
        ]);

        $workspace = Workspace::create([
            ...$data,
            'brand_status' => 'pending',
        ]);

        ExtractWorkspaceBrandKit::dispatch($workspace->id)->afterCommit();

        return response()->json([
            'id' => $workspace->id,
            'brand_status' => $workspace->brand_status,
        ], 202);
    }
}
<?php
// app/Jobs/ExtractWorkspaceBrandKit.php

namespace App\Jobs;

use App\Models\Workspace;
use App\Services\BrandKitClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

final class ExtractWorkspaceBrandKit implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;
    public int $timeout = 60;

    public function __construct(public int $workspaceId) {}

    public function handle(BrandKitClient $client): void
    {
        $workspace = Workspace::findOrFail($this->workspaceId);
        $workspace->update(['brand_status' => 'processing']);

        try {
            $brand = $client->extract($workspace->website_url);

            $workspace->update([
                ...$brand->columns(),
                'brand_status' => 'ready',
                'brand_failure' => null,
            ]);
        } catch (Throwable $exception) {
            $reason = $exception->getMessage();

            $workspace->update([
                'brand_status' => 'failed',
                'brand_failure' => $reason,
            ]);

            Log::warning('Brand-kit extraction failed', [
                'workspace_id' => $workspace->id,
                'reason' => $reason,
            ]);
        }
    }
}

Register the route in routes/api.php:

use App\Http\Controllers\WorkspaceController;
use Illuminate\Support\Facades\Route;

Route::post('/workspaces', [WorkspaceController::class, 'store'])
    ->middleware('auth:sanctum');

Authorization should also confirm that the authenticated user may create workspaces. Never expose this as an anonymous extraction proxy.

Test without contacting the service

Laravel’s HTTP fake provides a deterministic transport and lets the test verify the exact method, endpoint, token, and JSON body.

<?php
// tests/Feature/WorkspaceBrandKitTest.php

namespace Tests\Feature;

use App\Jobs\ExtractWorkspaceBrandKit;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class WorkspaceBrandKitTest extends TestCase
{
    use RefreshDatabase;

    public function test_job_prefills_a_workspace(): void
    {
        config([
            'services.brand_kit.token' => 'test-token',
            'services.brand_kit.endpoint' =>
                'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        ]);

        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([
                'brand_name' => 'Example Studio',
                'logos' => [['url' => 'https://example.com/logo.svg']],
                'colors' => ['primary' => '#123456'],
                'fonts' => ['heading' => 'Example Sans'],
                'imagery' => [],
                'social_profiles' => [],
                'css_variables' => ['--brand-primary' => '#123456'],
            ]),
        ]);

        $workspace = Workspace::create([
            'name' => 'Example',
            'website_url' => 'https://example.com',
            'brand_status' => 'pending',
        ]);

        app(ExtractWorkspaceBrandKit::class, [
            'workspaceId' => $workspace->id,
        ])->handle(app(\App\Services\BrandKitClient::class));

        $workspace->refresh();

        $this->assertSame('ready', $workspace->brand_status);
        $this->assertSame('Example Studio', $workspace->brand_name);
        $this->assertSame('#123456', $workspace->colors['primary']);

        Http::assertSent(fn ($request) =>
            $request->method() === 'POST' &&
            $request->url() === config('services.brand_kit.endpoint') &&
            $request->hasHeader('Authorization', 'Bearer test-token') &&
            $request['url'] === 'https://example.com'
        );
    }
}

Add companion tests for a 401 response, a 429 followed by success, malformed successful JSON, and a payload missing each required category. Assert that permanent failures leave the workspace in failed state and never persist partial brand data.

Deploy and operate the integration

Run migrations, cache environment-backed configuration, and restart long-running workers after changing the token:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=1 --timeout=60

Use a durable production queue rather than the synchronous driver. Keep the worker’s timeout above the client’s worst bounded execution time, and configure the queue’s retry-after interval above the worker timeout so another worker does not claim the same job prematurely.

Monitor counts and latency for ready and failed transitions. Logs should contain workspace identifiers and normalized failure codes, never tokens, authorization headers, full upstream bodies, or URLs containing query strings.

Common failures are straightforward to classify:

  • 401 or 403: verify plan activation and replace a revoked or incorrect service token.
  • 400 or 422: check the submitted public URL; repeating the same request is not useful.
  • 429: respect the bounded backoff, examine plan limits, and avoid manual retry storms.
  • 5xx or connection failure: retain the workspace and offer a controlled retry action.
  • Invalid successful payload: fail closed and compare the documented response contract with the boundary mapper before deployment.

Final verification checklist

  1. Confirm the token exists only in environment-backed secret storage.
  2. Submit a workspace with a public HTTP or HTTPS website.
  3. Verify the API responds with 202 and a pending status.
  4. Confirm a worker changes the state to processing, then ready.
  5. Inspect the stored brand name, logos, colors, fonts, imagery, social profiles, and CSS variables.
  6. Exercise authentication, validation, rate-limit, malformed-response, and connection-failure tests.
  7. Verify logs expose neither the service token nor an upstream response body.

The best onboarding automation does not merely make a form faster. It turns uncertain external data into explicit application state. With the remote call isolated, every contracted category validated, retries bounded, and failures visible, a new workspace can arrive already recognizable as the client’s own while remaining safe to operate when the network—or the website being analyzed—is less cooperative.

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

Mihajlo

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