Laravel: Izdvojite setove brenda za trenutačne nacrte odredišnih stranica
A customer pastes their website into your onboarding form and expects a polished landing page moments later. The difficult part is not generating markup. It is translating an existing visual identity into a draft without copying unsafe CSS, trusting malformed URLs, blocking the request cycle, or turning a third-party outage into a broken onboarding flow.
This tutorial builds that production path in Laravel and PHP 8.3+. A queued job sends the customer’s public website to the Brand Kit Extractor API, validates the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables, then stores a deliberately restricted theme draft. The result is useful immediately, but remains a draft that a customer can review before publication.
Get access before writing integration code
Start by creating an account at the registration page, or use the sign-in page if you already have one.
- Open the Brand Kit Extractor service page.
- Choose the available Free, Plus, or Pro plan and complete activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token.
- Store it in your application’s environment configuration. Never commit it, log it, or place it in test fixtures.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update the secret in every running environment, restart workers, verify a request, and then remove any obsolete secret-manager version.
The API requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. This implementation uses a Bearer token because query parameters commonly appear in access logs, proxies, browser history, and monitoring systems.
The exact request is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit with a JSON body containing url. Before building the feature, make one minimal test request from a trusted terminal:
export BRAND_KIT_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body \
--request POST \
--url 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
--header "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"url":"https://customer.example"}'
Now place the credential in the project’s uncommitted .env file:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT=3
BRAND_KIT_TIMEOUT=15
QUEUE_CONNECTION=database
Shape the feature around a recoverable workflow
You need PHP 8.3+, Composer, a Laravel application, a database, and a functioning queue connection. Create the application and its core classes with standard Laravel tooling:
composer create-project laravel/laravel landing-drafts
cd landing-drafts
php artisan make:model BrandKitDraft -m
php artisan make:request StoreBrandKitDraftRequest
php artisan make:controller BrandKitDraftController
php artisan make:job ExtractBrandKit
php artisan make:test BrandKitClientTest --unit
php artisan make:test NormalizedBrandKitTest --unit
The controller should only validate input, create a pending record, and dispatch work. A queue is worthwhile here because website extraction is network-bound and may take longer than a comfortable onboarding request. The worker owns bounded retries and state transitions; the browser receives a draft identifier immediately.
The important project files are:
app/Services/BrandKitClient.phpfor the HTTP boundaryapp/Data/NormalizedBrandKit.phpfor defensive response mappingapp/Jobs/ExtractBrandKit.phpfor asynchronous orchestrationapp/Http/Requests/StoreBrandKitDraftRequest.phpfor public-URL validationapp/Http/Controllers/BrandKitDraftController.phpfor onboardingapp/Models/BrandKitDraft.phpand its migration for persistence
Centralize configuration
Add this entry to config/services.php. Reading environment variables only through configuration keeps config:cache safe and makes tests straightforward.
'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', 3),
'timeout' => (int) env('BRAND_KIT_TIMEOUT', 15),
],
Persist explicit states, not ambiguous nulls
The draft table records whether extraction is pending, ready, or failed. Store a stable failure code rather than a raw upstream response, which could expose implementation details or unexpectedly contain sensitive data.
<?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('brand_kit_drafts', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('source_url', 2048);
$table->string('status', 20)->default('pending')->index();
$table->json('brand_data')->nullable();
$table->string('failure_code', 40)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('brand_kit_drafts');
}
};
The model needs user_id, source_url, status, brand_data, and failure_code in $fillable, plus a cast of brand_data to array.
Validate the customer URL at your boundary
The service extracts public websites, so reject non-HTTP schemes, credentials embedded in URLs, fragments, query strings, localhost names, and private or reserved IP literals. If onboarding already verifies domain ownership, also require the submitted host to match that verified domain.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
final class StoreBrandKitDraftRequest extends FormRequest
{
public function rules(): array
{
return ['url' => ['required', 'url:http,https', 'max:2048']];
}
public function after(): array
{
return [function (Validator $validator): void {
$url = (string) $this->input('url');
$parts = parse_url($url);
if (! is_array($parts) || isset($parts['user'], $parts['pass'])
|| isset($parts['query']) || isset($parts['fragment'])) {
$validator->errors()->add('url', 'Use a public homepage URL without credentials, a query, or a fragment.');
return;
}
$host = strtolower($parts['host'] ?? '');
if ($host === 'localhost' || str_ends_with($host, '.local')) {
$validator->errors()->add('url', 'The website must be publicly reachable.');
return;
}
if (filter_var($host, FILTER_VALIDATE_IP)
&& ! filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
$validator->errors()->add('url', 'Private and reserved addresses are not allowed.');
}
}];
}
}
This check is intentionally conservative, but it is not a complete DNS-rebinding defense. Do not resolve arbitrary hosts inside the web request merely to “prove” they are public. Prefer a domain already verified during onboarding, while the extraction provider independently enforces its own outbound-request controls.
Build a bounded, status-aware HTTP client
Laravel’s built-in HTTP client provides the required timeouts and test fakes. Retry only failures that might succeed without changing the request: connection errors, rate limits, and server errors. Authentication and validation failures need human or configuration changes, so retrying them only wastes quota.
<?php
namespace App\Services;
use App\Data\NormalizedBrandKit;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
final class BrandKitApiException extends RuntimeException
{
public function __construct(public readonly string $kind)
{
parent::__construct($kind);
}
}
final class BrandKitClient
{
public function extract(string $url): NormalizedBrandKit
{
$token = (string) config('services.brand_kit.token');
if ($token === '') {
throw new BrandKitApiException('configuration');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->withToken($token)
->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 BrandKitApiException('connection');
}
sleep($attempt);
continue;
}
if (in_array($response->status(), [401, 403], true)) {
throw new BrandKitApiException('authentication');
}
if ($response->status() === 422) {
throw new BrandKitApiException('request_validation');
}
if ($response->status() === 429 || $response->serverError()) {
if ($attempt === 3) {
throw new BrandKitApiException(
$response->status() === 429 ? 'rate_limited' : 'upstream'
);
}
$retryAfter = $response->header('Retry-After');
$delay = ctype_digit((string) $retryAfter)
? min(10, max(1, (int) $retryAfter))
: $attempt;
sleep($delay);
continue;
}
if (! $response->successful()) {
throw new BrandKitApiException('unexpected_status');
}
$payload = $response->json();
if (! is_array($payload)) {
throw new BrandKitApiException('invalid_response');
}
try {
return NormalizedBrandKit::fromApi($payload);
} catch (Throwable $exception) {
Log::warning('Brand kit response failed validation', [
'status' => $response->status(),
]);
throw new BrandKitApiException('invalid_response');
}
}
throw new BrandKitApiException('upstream');
}
}
No token, URL, response body, or authorization header enters the log. If the provider returns an HTTP-date rather than an integer in Retry-After, the client uses its short bounded backoff instead of sleeping a worker indefinitely.
Turn evidence into a safe theme
The external response is data, not executable presentation. Validate every required contract area before storage. Then allow only HTTPS asset URLs, hexadecimal colors, restrained font names, and syntactically valid CSS-variable keys. Do not paste upstream CSS into a style element.
<?php
namespace App\Data;
use UnexpectedValueException;
final readonly class NormalizedBrandKit
{
public function __construct(public array $value) {}
public static function fromApi(array $data): self
{
$name = $data['brand_name'] ?? null;
if (! is_string($name) || trim($name) === '' || mb_strlen($name) > 120) {
throw new UnexpectedValueException('Invalid brand name');
}
foreach (['logos', 'colors', 'fonts', 'imagery', 'social_profiles', 'css_variables'] as $field) {
if (! array_key_exists($field, $data) || ! is_array($data[$field])) {
throw new UnexpectedValueException("Invalid {$field}");
}
}
foreach ($data['css_variables'] as $key => $value) {
if (! is_string($key)
|| ! preg_match('/\A--[a-z][a-z0-9-]{0,62}\z/', $key)
|| ! is_scalar($value)
|| strlen((string) $value) > 200
|| preg_match('/[;{}]|url\s*\(/i', (string) $value)) {
throw new UnexpectedValueException('Unsafe CSS variable');
}
}
$strings = fn (array $items): array => array_values(array_filter(
$items,
'is_string'
));
$colors = array_values(array_filter(
$strings($data['colors']),
fn (string $value): bool => (bool) preg_match(
'/\A#[0-9a-f]{6}\z/i',
$value
)
));
$fonts = array_values(array_filter(
$strings($data['fonts']),
fn (string $value): bool => (bool) preg_match(
'/\A[\pL\pN ._-]{1,80}\z/u',
$value
)
));
$httpsUrls = fn (array $items): array => array_values(array_filter(
$strings($items),
fn (string $value): bool => parse_url($value, PHP_URL_SCHEME) === 'https'
&& is_string(parse_url($value, PHP_URL_HOST))
));
return new self([
'brand_name' => trim($name),
'logos' => $httpsUrls($data['logos']),
'colors' => $colors,
'fonts' => $fonts,
'imagery' => $httpsUrls($data['imagery']),
'social_profiles' => $httpsUrls($data['social_profiles']),
'theme' => [
'--brand-primary' => $colors[0] ?? '#1f2937',
'--brand-font' => $fonts[0] ?? 'system-ui',
],
]);
}
}
This example expects the seven named contract fields at the response root and treats their nested contents conservatively. If the official documentation for your activated service shows an envelope or structured objects, adapt only this boundary mapper. Do not scatter response-shape assumptions through controllers and templates.
The original CSS variables are validated but deliberately not stored. The application derives its own small theme vocabulary from validated colors and fonts. That trade-off sacrifices some visual fidelity in exchange for predictable rendering and a much smaller injection surface.
Queue extraction and expose the onboarding route
<?php
namespace App\Jobs;
use App\Models\BrandKitDraft;
use App\Services\BrandKitApiException;
use App\Services\BrandKitClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class ExtractBrandKit implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 60;
public function __construct(public readonly int $draftId) {}
public function handle(BrandKitClient $client): void
{
$draft = BrandKitDraft::query()->findOrFail($this->draftId);
try {
$kit = $client->extract($draft->source_url);
$draft->update([
'status' => 'ready',
'brand_data' => $kit->value,
'failure_code' => null,
]);
} catch (BrandKitApiException $exception) {
$draft->update([
'status' => 'failed',
'failure_code' => $exception->kind,
]);
Log::warning('Brand kit extraction failed', [
'draft_id' => $draft->id,
'kind' => $exception->kind,
]);
}
}
}
The job has one queue attempt because the client already performs three tightly controlled attempts. Stacking queue retries on top would silently multiply API traffic. A failed draft can instead expose an explicit “try again” action, subject to application-level throttling.
In the controller, create the record through the authenticated user, dispatch ExtractBrandKit::dispatch($draft->id), and return a 202 JSON response containing the draft ID and pending status. Register the route behind authentication:
use App\Http\Controllers\BrandKitDraftController;
use Illuminate\Support\Facades\Route;
Route::post('/onboarding/brand-kit', BrandKitDraftController::class)
->middleware(['auth', 'throttle:10,1']);
When displaying a ready draft, render the brand name as escaped text and apply only the application-generated theme keys. Fetch remote logos and imagery through an image proxy or a reviewed import pipeline if your threat model does not permit browsers to contact third-party hosts directly.
Test retries and hostile responses without making network calls
Http::fake() gives deterministic coverage of the API boundary. Test both the successful retry path and rejection of CSS-shaped payloads:
<?php
namespace Tests\Unit;
use App\Data\NormalizedBrandKit;
use App\Services\BrandKitClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
use UnexpectedValueException;
final class BrandKitClientTest extends TestCase
{
public function test_it_retries_a_server_error_and_maps_the_result(): void
{
config()->set('services.brand_kit.token', 'test-token');
config()->set('services.brand_kit.endpoint', 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit');
Http::fakeSequence()
->push([], 503)
->push([
'brand_name' => 'Example',
'logos' => ['https://customer.example/logo.svg'],
'colors' => ['#123456'],
'fonts' => ['Inter'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--primary' => '#123456'],
], 200);
$result = app(BrandKitClient::class)
->extract('https://customer.example');
$this->assertSame('#123456', $result->value['theme']['--brand-primary']);
Http::assertSentCount(2);
}
public function test_it_rejects_executable_css_values(): void
{
$this->expectException(UnexpectedValueException::class);
NormalizedBrandKit::fromApi([
'brand_name' => 'Example',
'logos' => [],
'colors' => [],
'fonts' => [],
'imagery' => [],
'social_profiles' => [],
'css_variables' => [
'--primary' => 'red; background:url(https://attacker.example)',
],
]);
}
}
Add feature tests for authentication, throttling, private-address rejection, ownership of returned draft IDs, and the 202 response. Keep tokens fictional in every fixture.
Deploy it as an observable integration
Run migrations, cache configuration, and restart long-lived workers after changing secrets or code:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan test
Operate at least one supervised queue worker for the selected connection. Alert on rising counts of authentication, rate_limited, connection, upstream, and invalid_response failures. Track completion latency and pending-draft age, but avoid high-cardinality labels such as customer URLs.
Common failures worth diagnosing explicitly
- Authentication: confirm the service-scoped token is active and that regeneration did not revoke the deployed value.
- Request validation: verify the URL is public and that the JSON property is exactly
url. - Rate limiting: respect the bounded delay, stop after three attempts, and offer a later user-initiated retry.
- Invalid response: compare the current official documentation with the boundary mapper before changing stored data.
- Drafts remain pending: confirm the queue worker uses the same environment and queue connection as the web application.
- Stale credentials after deployment: rebuild the configuration cache and restart queue workers.
Final verification checklist
- The service plan is active and the token comes from the documentation page’s Service token panel.
- The token exists only in environment-backed configuration.
- The application sends a Bearer-authenticated JSON
POSTto the exact extraction endpoint. - Customer URLs are authenticated, throttled, and restricted to public HTTP or HTTPS locations.
- Every required response area is validated before storage.
- Remote CSS is never rendered directly, and only HTTPS asset URLs survive normalization.
- Retries are bounded and exclude authentication and request-validation failures.
- Logs contain draft IDs and stable failure kinds, never tokens or response bodies.
- Queue workers are supervised, tests pass with fake HTTP responses, and a real onboarding run reaches
ready.
The lasting design lesson is simple: extraction should provide evidence, not authority. Let the service identify a customer’s visual language, but keep your application responsible for validation, storage, rendering, and publication. That boundary turns a convenient onboarding shortcut into a dependable landing-page workflow.