Tutorials

Laravel: Auto-Brand New Client Workspaces with AI Extracted Logo, Colors, Fonts

Laravel: Auto-Brand New Client Workspaces with AI Extracted Logo, Colors, Fonts

A blank client workspace creates immediate friction. Someone has to find the correct logo, copy colors from a website, identify fonts, and turn all of that into usable settings before the real work can begin.

This Laravel implementation removes that setup tax. Creating a workspace immediately returns a usable record, then a queued job sends the client’s public website to the Brand Kit Extractor API. The result is validated at the application boundary and stored as structured logo, color, font, imagery, social-profile, and CSS-variable data.

The important production detail is that extraction is asynchronous. A remote website may be slow, unavailable, or rate-limited; none of those conditions should make workspace creation feel broken.

Get access and create a service token

First, register for an account, or use the sign-in page if you already have one.

  1. Open the Brand Kit Extractor service page.
  2. Choose an 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 your password manager and the deployment platform’s secret store.

This service requires authentication; it is not a token-free endpoint. 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 and access logs.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update every environment that uses the service, deploy or reload configuration, and only then verify extraction.

Confirm the exact API call

The request is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit, with a JSON body containing url. Before writing Laravel code, make one minimal request from a trusted terminal:

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"}'

Do not paste the resulting token or response into source control. Put the credential in Laravel’s local .env file and use the deployment platform’s environment configuration in production:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit
BRAND_KIT_CONNECT_TIMEOUT=5
BRAND_KIT_TIMEOUT=25

QUEUE_CONNECTION=database

Architecture and project structure

This feature has two distinct transactions. The HTTP request creates the workspace with a pending status. After the database transaction commits, a queue job performs extraction and changes the status to ready, retrying, or failed.

That separation keeps user-facing latency predictable and gives transient failures a controlled retry path. The trade-off is eventual consistency: the workspace exists before its brand data does, so the interface must display the current status and poll or refresh until extraction finishes.

app/
  Data/BrandKit.php
  Exceptions/BrandKitException.php
  Http/Controllers/WorkspaceController.php
  Jobs/ExtractBrandKit.php
  Models/Workspace.php
  Services/BrandKitClient.php
config/services.php
database/migrations/..._create_workspaces_table.php
routes/api.php
tests/Feature/BrandKitClientTest.php
tests/Feature/ExtractBrandKitTest.php

You need PHP 8.3 or newer, an existing Laravel application, a supported database, and a configured queue backend. A database queue is sufficient for a small installation; create its table when the application does not already have one:

php artisan make:queue-table
php artisan migrate

Configure Laravel and persist explicit states

Add one environment-backed entry to config/services.php. Keeping the endpoint configurable helps testing, while the default preserves the exact production URL.

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

The workspace stores each part independently. This makes application-level access straightforward and avoids treating an unvalidated API response as an opaque JSON blob.

<?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')->default('pending');
            $table->string('brand_name')->nullable();
            $table->json('brand_logos')->nullable();
            $table->json('brand_colors')->nullable();
            $table->json('brand_fonts')->nullable();
            $table->json('brand_imagery')->nullable();
            $table->json('brand_social_profiles')->nullable();
            $table->json('brand_css_variables')->nullable();
            $table->text('brand_error')->nullable();
            $table->timestamps();
        });
    }

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

In app/Models/Workspace.php, make the user-supplied fields assignable and cast the extracted collections:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

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

    protected function casts(): array
    {
        return [
            'brand_logos' => 'array',
            'brand_colors' => 'array',
            'brand_fonts' => 'array',
            'brand_imagery' => 'array',
            'brand_social_profiles' => 'array',
            'brand_css_variables' => 'array',
        ];
    }
}

Validate the response at the API boundary

Successful HTTP status does not make remote data trustworthy. The mapper below requires the complete contract: brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. It also rejects excessive nesting and responses larger than 512 KiB before anything reaches the database.

<?php

namespace App\Data;

use JsonException;
use UnexpectedValueException;

final readonly class BrandKit
{
    public function __construct(
        public string $name,
        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) === '') {
            throw new UnexpectedValueException('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 UnexpectedValueException("Invalid {$field}.");
            }

            self::assertJsonTree($data[$field]);
        }

        try {
            $encoded = json_encode($data, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new UnexpectedValueException('Response is not valid JSON data.', 0, $e);
        }

        if (strlen($encoded) > 524288) {
            throw new UnexpectedValueException('Brand kit response is too large.');
        }

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

    private static function assertJsonTree(mixed $value, int $depth = 0): void
    {
        if ($depth > 6) {
            throw new UnexpectedValueException('Response nesting is too deep.');
        }

        if (is_array($value)) {
            foreach ($value as $child) {
                self::assertJsonTree($child, $depth + 1);
            }

            return;
        }

        if (! is_null($value) && ! is_scalar($value)) {
            throw new UnexpectedValueException('Unsupported response value.');
        }
    }
}

This validation deliberately preserves evidence and metadata inside the collections instead of guessing that every logo is a string or every color is a hex value. UI-specific normalization belongs in a second layer after you have confirmed the documented shapes your interface consumes.

Build a bounded, selective-retry HTTP client

Create a small exception that tells the queue whether retrying is useful:

<?php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

final class BrandKitException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        string $message,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

The client retries connection failures, 429, and server errors. Authentication failures, request validation failures, and malformed successful responses are not retried blindly.

<?php

namespace App\Services;

use App\Data\BrandKit;
use App\Exceptions\BrandKitException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

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

        if ($token === '') {
            throw new BrandKitException(
                'configuration',
                false,
                'Brand Kit service token is not configured.'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->acceptJson()
                    ->asJson()
                    ->connectTimeout((int) config('services.brand_kit.connect_timeout'))
                    ->timeout((int) config('services.brand_kit.timeout'))
                    ->post($endpoint, ['url' => $url]);
            } catch (ConnectionException $e) {
                if ($attempt === 3) {
                    throw new BrandKitException(
                        'connection',
                        true,
                        'Brand Kit service could not be reached.',
                        $e
                    );
                }

                $this->pause($attempt, null, $url);
                continue;
            }

            if ($response->successful()) {
                $payload = $response->json();

                if (! is_array($payload)) {
                    throw new BrandKitException(
                        'invalid_response',
                        false,
                        'Brand Kit service returned invalid JSON.'
                    );
                }

                try {
                    return BrandKit::fromApi($payload);
                } catch (Throwable $e) {
                    throw new BrandKitException(
                        'invalid_response',
                        false,
                        'Brand Kit response failed schema validation.',
                        $e
                    );
                }
            }

            if (in_array($response->status(), [401, 403], true)) {
                throw new BrandKitException(
                    'authentication',
                    false,
                    'Brand Kit service rejected its token.'
                );
            }

            if ($response->status() === 422) {
                throw new BrandKitException(
                    'request_validation',
                    false,
                    'Brand Kit service rejected the website URL.'
                );
            }

            if ($response->status() === 429 || $response->serverError()) {
                if ($attempt < 3) {
                    $this->pause($attempt, $response->header('Retry-After'), $url);
                    continue;
                }

                throw new BrandKitException(
                    'upstream_transient',
                    true,
                    'Brand Kit service remained unavailable or rate-limited.'
                );
            }

            throw new BrandKitException(
                'upstream_rejection',
                false,
                "Brand Kit service returned HTTP {$response->status()}."
            );
        }

        throw new BrandKitException('unexpected', true, 'Extraction did not complete.');
    }

    private function pause(int $attempt, ?string $retryAfter, string $url): void
    {
        $seconds = ctype_digit((string) $retryAfter)
            ? min(4, max(1, (int) $retryAfter))
            : min(4, 2 ** ($attempt - 1));

        Log::warning('Brand kit request will be retried.', [
            'attempt' => $attempt,
            'host' => parse_url($url, PHP_URL_HOST),
            'delay_seconds' => $seconds,
        ]);

        usleep($seconds * 1_000_000);
    }
}

Notice what is absent from the log: the token, response body, full URL, and extracted social data. A hostname, attempt number, status category, and workspace identifier are usually enough to investigate operational failures.

Create the workspace and dispatch extraction

The controller rejects non-HTTP schemes, local hostnames, and explicitly private IP addresses. For stricter products, add an approved-domain policy or ownership verification rather than accepting arbitrary customer input.

<?php

namespace App\Http\Controllers;

use App\Jobs\ExtractBrandKit;
use App\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class WorkspaceController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $input = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'website_url' => ['required', 'url', 'max:2048'],
        ]);

        $this->assertPublicUrl($input['website_url']);

        $workspace = DB::transaction(function () use ($input): Workspace {
            $workspace = Workspace::create($input);
            ExtractBrandKit::dispatch($workspace->id)->afterCommit();

            return $workspace;
        });

        return response()->json($workspace, 202);
    }

    public function show(Workspace $workspace): JsonResponse
    {
        return response()->json($workspace);
    }

    private function assertPublicUrl(string $url): void
    {
        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
        $host = strtolower((string) parse_url($url, PHP_URL_HOST));

        $invalidHost = $host === ''
            || $host === 'localhost'
            || str_ends_with($host, '.local');

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            $invalidHost = ! filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            );
        }

        if (! in_array($scheme, ['http', 'https'], true) || $invalidHost) {
            throw ValidationException::withMessages([
                'website_url' => 'Enter a public HTTP or HTTPS website.',
            ]);
        }
    }
}

Protect both routes with your application’s normal authentication and workspace-authorization policies:

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

Route::middleware('auth:sanctum')->group(function (): void {
    Route::post('/workspaces', [WorkspaceController::class, 'store']);
    Route::get('/workspaces/{workspace}', [WorkspaceController::class, 'show']);
});

Run the idempotent queue job

<?php

namespace App\Jobs;

use App\Exceptions\BrandKitException;
use App\Models\Workspace;
use App\Services\BrandKitClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;

final class ExtractBrandKit implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public int $timeout = 100;
    public array $backoff = [60, 300, 900];

    public function __construct(public readonly int $workspaceId) {}

    public function handle(BrandKitClient $client): void
    {
        $workspace = Workspace::find($this->workspaceId);

        if (! $workspace || $workspace->brand_status === 'ready') {
            return;
        }

        $workspace->forceFill([
            'brand_status' => 'processing',
            'brand_error' => null,
        ])->save();

        try {
            $kit = $client->extract($workspace->website_url);
        } catch (BrandKitException $e) {
            $workspace->forceFill([
                'brand_status' => $e->retryable ? 'retrying' : 'failed',
                'brand_error' => $e->kind,
            ])->save();

            if ($e->retryable) {
                throw $e;
            }

            return;
        }

        $workspace->forceFill([
            'brand_status' => 'ready',
            'brand_name' => $kit->name,
            'brand_logos' => $kit->logos,
            'brand_colors' => $kit->colors,
            'brand_fonts' => $kit->fonts,
            'brand_imagery' => $kit->imagery,
            'brand_social_profiles' => $kit->socialProfiles,
            'brand_css_variables' => $kit->cssVariables,
            'brand_error' => null,
        ])->save();
    }

    public function failed(Throwable $exception): void
    {
        Workspace::whereKey($this->workspaceId)->update([
            'brand_status' => 'failed',
            'brand_error' => 'retries_exhausted',
        ]);
    }
}

Test without calling the real service

Http::fake() makes the API boundary deterministic and prevents tests from spending quota. This test verifies authentication, request shape, response mapping, and storage through the job.

<?php

namespace Tests\Feature;

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

final class ExtractBrandKitTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_prefills_workspace_brand_data(): void
    {
        config()->set('services.brand_kit.endpoint', 'https://brand.test/extract');
        config()->set('services.brand_kit.token', 'test-token');

        Http::preventStrayRequests();
        Http::fake([
            'https://brand.test/extract' => Http::response([
                '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'],
            ], 200),
        ]);

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

        (new ExtractBrandKit($workspace->id))
            ->handle(app(BrandKitClient::class));

        $workspace->refresh();

        $this->assertSame('ready', $workspace->brand_status);
        $this->assertSame('Example', $workspace->brand_name);
        $this->assertSame('#112233', $workspace->brand_colors[0]['value']);

        Http::assertSent(fn ($request) =>
            $request->url() === 'https://brand.test/extract'
            && $request['url'] === 'https://example.com'
            && $request->hasHeader('Authorization', 'Bearer test-token')
        );
    }
}

Add separate cases for 401, 422, 429, malformed JSON, missing fields, and an exhausted connection failure. Assert that permanent errors finish as failed, while transient errors are rethrown for the queue’s delayed retry.

Security, operations, and deployment

  • Treat extracted content as untrusted. Escape names and metadata when rendering. Do not inject returned CSS variables directly into a global stylesheet; allow only property names and value formats your product explicitly supports.
  • Handle remote assets carefully. Logo and imagery URLs may change or track visitors. Apply an appropriate Content Security Policy, or fetch and validate assets through a controlled ingestion process before serving them as workspace media.
  • Minimize stored errors. Store stable categories such as authentication or request_validation, not upstream bodies that might contain sensitive information.
  • Monitor outcomes. Track counts and age for pending, retrying, ready, and failed workspaces. An old pending record is more actionable than a generic queue-depth alert.
  • Rotate safely. Because regeneration revokes the old service token, coordinate secret replacement with configuration reloads and queue-worker restarts.

Deploy the migration and cached configuration before restarting workers:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=4 --timeout=120

Ensure the queue connection’s retry_after exceeds the worker timeout so another worker does not pick up the same job while it is still running. In a supervised production process, the process manager should run queue:work; the command above is the worker shape, not a replacement for supervision.

Common failures and final verification

A 401 or 403 usually means the token is missing, mistyped, revoked, or unavailable because configuration was cached before the environment changed. A 422 indicates that the submitted URL was rejected and should be corrected, not retried. Repeated 429 responses point to quota or rate limits; retain backoff and review the active plan instead of increasing concurrency.

If workspaces remain pending, confirm that a queue worker is running against the same queue connection as the web application. If extraction succeeds but the interface remains blank, inspect the stored arrays and the frontend’s field mapping before weakening boundary validation.

  • The account and Free, Plus, or Pro plan are active.
  • The service-scoped token is present in environment-backed configuration.
  • A manual POST to the exact endpoint succeeds with a JSON url.
  • Creating a workspace returns HTTP 202 without waiting for extraction.
  • The committed transaction dispatches exactly one queue job.
  • The status progresses from pending to processing and then ready.
  • Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
  • Authentication and validation failures are not retried.
  • Rate limits, server failures, and connection failures use bounded retries and backoff.
  • Logs and test fixtures contain no real service token or sensitive response body.

The polished result is not merely an API call. It is a workspace that appears immediately, enriches itself safely in the background, reports failure honestly, and gives users a practical starting point instead of an empty canvas. That is the difference between attaching an AI service and turning it into a dependable product feature.

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.