Laravel Proposals: Umetanje resursa kompleta robne marke za dosljedna izvješća za klijente
A proposal can have flawless pricing and persuasive copy yet still feel unfinished when the client’s logo, colors, and typography are inconsistent. Copying those details by hand is slow and error-prone. A better workflow imports a public website’s visual identity once, validates it at the application boundary, and makes the resulting brand kit available to every proposal and report.
This tutorial builds that workflow in Laravel on PHP 8.3 or later. A console command calls the Brand Kit Extractor API, validates the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables, then stores an idempotent snapshot for the report generator. The integration uses Laravel’s built-in HTTP client, bounded retries, structured failures, safe logging, and deterministic tests.
Get access and create a service token
- Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
- Open the Brand Kit Extractor service page. Choose the available Free, Plus, or Pro plan that fits your expected usage and complete activation.
- Visit the official service documentation. Find the Service token panel and copy the service-scoped token displayed there.
- Store the token immediately in a password manager or deployment secret store. Regenerating the token revokes the previously active token, so every deployed environment using the old value must be updated.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because query parameters commonly appear in access logs and monitoring traces.
Confirm the endpoint before writing Laravel code
The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON request body contains url. Make a minimal request with a public website you are authorized to process:
curl --request POST \
'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://client.example"}'
Do not paste the response directly into your database. The remote boundary must first verify that it contains usable brand data with the expected types.
Configure the Laravel application
The project needs PHP 8.3 or later, a supported Laravel application, a configured database, and outbound HTTPS access. Put the credential in .env, never in committed PHP files:
BRAND_KIT_API_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT=5
BRAND_KIT_RESPONSE_TIMEOUT=30
Add an environment-backed entry to config/services.php:
'brand_kit' => [
'base_url' => 'https://ai.mihajlo.mk/api/brand-kit-extractor',
'token' => env('BRAND_KIT_API_TOKEN'),
'connect_timeout' => (int) env('BRAND_KIT_CONNECT_TIMEOUT', 5),
'timeout' => (int) env('BRAND_KIT_RESPONSE_TIMEOUT', 30),
],
The architecture is deliberately small: an API service owns transport behavior, a domain object validates the response, a command coordinates persistence, and the proposal controller reads only validated snapshots. Extraction is an operator-triggered import rather than part of a customer-facing request, so a queue is unnecessary here. If imports later become frequent, the command’s orchestration can move into a queued job without changing the API boundary.
Create an idempotent brand-kit store
Create a brand_kits migration and model. The unique source URL lets repeated imports update the same record instead of creating ambiguous duplicates.
<?php
// database/migrations/2026_01_01_000000_create_brand_kits_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('brand_kits', function (Blueprint $table): void {
$table->id();
$table->string('source_url', 2048)->unique();
$table->string('brand_name');
$table->json('payload');
$table->string('payload_hash', 64);
$table->timestamp('verified_at');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('brand_kits');
}
};
<?php
// app/Models/BrandKit.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class BrandKit extends Model
{
protected $fillable = [
'source_url',
'brand_name',
'payload',
'payload_hash',
'verified_at',
];
protected function casts(): array
{
return [
'payload' => 'array',
'verified_at' => 'immutable_datetime',
];
}
}
Run php artisan migrate after reviewing the migration for your database platform.
Validate the response at the domain boundary
The service contract exposes the semantic fields used below. Keeping validation in one object prevents controllers and templates from making optimistic assumptions about remote JSON. Arrays remain intentionally opaque because individual asset representations can contain evidence and metadata that the application should preserve rather than reinterpret.
<?php
// app/Data/ExtractedBrandKit.php
namespace App\Data;
use UnexpectedValueException;
final readonly class ExtractedBrandKit
{
private const ARRAY_FIELDS = [
'logos', 'colors', 'fonts', 'imagery', 'social_profiles',
];
private function __construct(
public string $name,
public array $payload,
) {}
public static function fromApi(array $data): self
{
if (!isset($data['name']) || !is_string($data['name'])
|| trim($data['name']) === '') {
throw new UnexpectedValueException('Invalid brand name.');
}
foreach (self::ARRAY_FIELDS as $field) {
if (!array_key_exists($field, $data) || !is_array($data[$field])) {
throw new UnexpectedValueException(
"Invalid or missing brand field: {$field}."
);
}
}
if (!array_key_exists('css_variables', $data)
|| !is_array($data['css_variables'])) {
throw new UnexpectedValueException(
'Invalid or missing brand field: css_variables.'
);
}
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
if (strlen($encoded) > 1_000_000) {
throw new UnexpectedValueException('Brand kit is too large.');
}
return new self(trim($data['name']), $data);
}
}
The one-megabyte ceiling is an application policy, not a statement about the API’s limit. It protects the report database from unexpectedly large documents and should be tuned from observed payload sizes.
Build the resilient API client
The client retries connection failures and server-side failures with short exponential backoff. It does not retry authentication, request-validation, or quota responses: repeating those calls immediately cannot repair the underlying condition.
<?php
// app/Services/BrandKitFailure.php
namespace App\Services;
use RuntimeException;
final class BrandKitFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
) {
parent::__construct("Brand kit extraction failed: {$kind}.");
}
}
// app/Services/BrandKitApi.php
namespace App\Services;
use App\Data\ExtractedBrandKit;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class BrandKitApi
{
public function extract(string $url): ExtractedBrandKit
{
$token = config('services.brand_kit.token');
if (!is_string($token) || $token === '') {
throw new BrandKitFailure('configuration');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::baseUrl(
config('services.brand_kit.base_url')
)
->withToken($token)
->acceptJson()
->asJson()
->connectTimeout(
config('services.brand_kit.connect_timeout')
)
->timeout(config('services.brand_kit.timeout'))
->post('/v1/extract-brand-kit', ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new BrandKitFailure('connection');
}
$this->backoff($attempt);
continue;
}
if ($response->successful()) {
try {
$json = $response->json();
if (!is_array($json)) {
throw new \UnexpectedValueException();
}
return ExtractedBrandKit::fromApi($json);
} catch (Throwable $exception) {
throw new BrandKitFailure(
'invalid_response',
$response->status()
);
}
}
$status = $response->status();
if (in_array($status, [401, 403], true)) {
throw new BrandKitFailure('authentication', $status);
}
if ($status === 429) {
throw new BrandKitFailure('quota_or_rate_limit', $status);
}
if ($status >= 400 && $status < 500) {
throw new BrandKitFailure('request_rejected', $status);
}
if ($attempt === 3) {
throw new BrandKitFailure('upstream', $status);
}
$this->backoff($attempt);
}
throw new BrandKitFailure('unexpected');
}
private function backoff(int $attempt): void
{
usleep(((200 * (2 ** ($attempt - 1))) + random_int(0, 100)) * 1000);
}
}
Notice what is absent from exceptions and logs: response bodies, request headers, and tokens. Remote error bodies can contain unexpected content, while authorization headers must never reach application logs.
Import a website’s brand kit
The command validates the URL before making an outbound request. Restricting imports to public HTTPS websites reduces accidental access to internal services. Stronger environments should also resolve the hostname and reject private, loopback, link-local, and reserved addresses after every redirect.
<?php
// app/Console/Commands/ImportBrandKit.php
namespace App\Console\Commands;
use App\Models\BrandKit;
use App\Services\BrandKitApi;
use App\Services\BrandKitFailure;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
final class ImportBrandKit extends Command
{
protected $signature = 'brand:import {url}';
protected $description = 'Import a validated brand kit for reports';
public function handle(BrandKitApi $api): int
{
$url = $this->argument('url');
if (!is_string($url)
|| filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
$this->error('Provide a valid public HTTPS URL.');
return self::INVALID;
}
try {
$kit = $api->extract($url);
$encoded = json_encode($kit->payload, JSON_THROW_ON_ERROR);
BrandKit::query()->updateOrCreate(
['source_url' => $url],
[
'brand_name' => $kit->name,
'payload' => $kit->payload,
'payload_hash' => hash('sha256', $encoded),
'verified_at' => now(),
]
);
Log::info('Brand kit imported', [
'host' => parse_url($url, PHP_URL_HOST),
'payload_hash' => hash('sha256', $encoded),
]);
$this->info("Imported brand kit: {$kit->name}");
return self::SUCCESS;
} catch (BrandKitFailure $failure) {
Log::warning('Brand kit import failed', [
'host' => parse_url($url, PHP_URL_HOST),
'kind' => $failure->kind,
'status' => $failure->status,
]);
$this->error($failure->getMessage());
return self::FAILURE;
}
}
}
Run the real import with php artisan brand:import https://client.example. The proposal generator can now load the record by client URL and pass $brandKit->payload.
Production hardening and common failures
- 401 or 403: confirm the token belongs to this service and has not been revoked by regeneration. Do not retry automatically.
- 429: record the structured failure, reduce import frequency, and retry later according to the service response and active plan rather than looping immediately.
- Invalid response: retain the previous verified snapshot, alert on the failure kind, and compare the current official documentation with the boundary mapper.
- Timeout or 5xx: the client makes only three bounded attempts. Persistent failures should surface to monitoring, not hold a worker indefinitely.
- Website mismatch: remember that extraction is evidence-based from the submitted public URL. Review imported assets before publishing a high-value proposal.
During deployment, inject BRAND_KIT_API_TOKEN through the platform’s secret manager, then run php artisan config:cache only after the environment is present. Restrict command execution to trusted operators, preserve the last valid database snapshot during outages, and monitor counts by failure kind and status without logging credentials or response bodies.
Final verification checklist
- The activated plan and service-scoped token belong to the Brand Kit Extractor service.
- The token exists only in environment-backed configuration and secret storage.
- The application posts JSON to the exact extraction endpoint with Bearer authentication.
- Every required brand category is validated before persistence.
- Authentication, validation, and quota failures are not blindly retried.
- Logs contain a hostname, status, failure kind, and payload hash, but no token or raw body.
- The report layer treats CSS and remote asset URLs as untrusted input.
- Automated tests pass without contacting the external service.
- A real import updates one idempotent record and remains available to the proposal generator.
The valuable part of this integration is not merely fetching a logo. It is establishing a controlled handoff between public brand evidence and a repeatable document workflow. Once that boundary is validated, observable, and safe under failure, consistent client reports stop depending on someone remembering which shade of blue was used last time.