Vodiči

Laravel Onboarding: Build Landing Page Drafts with Brand Kit Extractor API

Laravel Uvođenje: Izradite nacrte odredišnih stranica pomoću API-ja za ekstrakciju kompleta brenda

Uvođenje korisnika često zastane u istom nezgodnom trenutku: korisnik ima web-mjesto, ali vašoj aplikaciji i dalje trebaju logotip, paleta boja, tipografija i dovoljno vizualnog konteksta za izradu vjerodostojnog prvog nacrta. Traženje od ljudi da ponovno unose sve te podatke stvara prepreke. Ručno kopiranje stvara dodatni posao za podršku.

Bolji tijek rada jest izdvojiti dokaze s javnog web-mjesta korisnika, provjeriti ih na granici aplikacije i spremiti kao neodobreni nacrt teme odredišne stranice. Korisnik dobiva korisnu početnu točku bez dopuštanja da udaljeni podaci postanu izvršni HTML ili CSS.

Ovaj vodič gradi taj tijek rada uz PHP 8.3+, Laravelov HTTP klijent, posao izdvajanja u redu čekanja, obrambeno mapiranje odgovora i determinističke testove. API Brand Kit Extractor ostaje središnja integracija: pretvara vizualni identitet javnog web-mjesta u strukturirane podatke o brendu koji obuhvaćaju naziv brenda, logotipe, boje, fontove, slike, profile na društvenim mrežama i CSS varijable.

Dobijte pristup prije pisanja integracijskog koda

Ova usluga zahtijeva autentifikaciju; nije API bez tokena. Izradite račun na stranici za registraciju ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Brand Kit Extractor.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token vezan uz uslugu.
  5. Postavite token u konfiguraciju okruženja svojeg projekta. Nikada ga nemojte predavati u kontrolu izvornog koda.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao promjenu pri implementaciji: ažurirajte svako okruženje koje koristi token, ponovno izgradite predmemoriranu konfiguraciju, ponovno pokrenite radnike i provjerite integraciju.

Potvrdite krajnju točku minimalnim zahtjevom

Točan zahtjev je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit, s JSON tijelom koje sadržava url. Usluga prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ovaj projekt koristi Bearer autentifikaciju jer vjerodajnice u nizu upita mogu procuriti u zapisnike pristupa i sustave nadzora.

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://customer.example"}'

Pokrenite ovo samo s javnim web-mjestom za čiju ste obradu ovlašteni. Uspješan odgovor trebao bi sadržavati sedam dokumentiranih područja podataka o brendu. Nemojte pretpostaviti da same uspješne vrijednosti čine sigurnima za prikaz.

Pohranite vjerodajnicu u Laravelovu konfiguraciju

Dodajte tajnu i odabir reda čekanja u .env:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Dodajte ovaj unos unutar polja koje vraća config/services.php:

'brand_kit' => [
    'base_url' => 'https://ai.mihajlo.mk/api/brand-kit-extractor',
    'token' => env('BRAND_KIT_TOKEN'),
],

Zadržavanje fiksnog URL-a usluge u konfiguraciji olakšava testove, a istodobno osigurava da produkcijski kod poziva zadanu krajnju točku. Samo vjerodajnica dolazi iz okruženja.

Arhitektura: izdvajanje je dokaz, a ne objavljeni dizajn

Preglednik šalje URL web-mjesta autentificiranoj Laravel ruti. Kontroler primjenjuje osnovne provjere javnog URL-a, stvara nacrt na čekanju i šalje posao u red čekanja. Posao poziva namjenski API klijent, mapira odgovor u objekt domene i pohranjuje provjerene dokaze. Kasnija radnja pregleda može odobriti odabrane vrijednosti za objavu.

Izvršavanje u pozadini ovdje je korisno jer udaljeno web-mjesto mora biti pregledano, a zahtjev može naići na ograničavanje prometa ili privremene uzvodne pogreške. Vraćanje 202 Accepted održava odzivnost uvođenja korisnika, dok zapis u bazi podataka pruža eksplicitno stanje pending, processing, retrying, completed ili failed.

Važan je kompromis namjeran: ova implementacija pohranjuje cijelo stablo provjerenih dokaza, ali samo zasebno provjerene CSS varijable ispunjavaju uvjete za pretpregled teme. Nikada ne umeće udaljenu oznaku, ne preuzima vraćene resurse niti automatski objavljuje rezultat.

app/
  Data/BrandKitData.php
  Exceptions/BrandKitApiException.php
  Http/Controllers/OnboardingBrandDraftController.php
  Jobs/ExtractBrandKit.php
  Models/BrandThemeDraft.php
  Services/BrandKitClient.php
config/services.php
database/migrations/..._create_brand_theme_drafts_table.php
routes/web.php
tests/Feature/ExtractBrandKitTest.php

Izradite trajni nacrt

Generirajte model, migraciju, kontroler i posao. Ako tablice reda čekanja vaše baze podataka još ne postoje, generirajte i njih.

php artisan make:model BrandThemeDraft -m
php artisan make:controller OnboardingBrandDraftController
php artisan make:job ExtractBrandKit
php artisan make:queue-table
php artisan migrate

Migracija nacrta bilježi i stanje životnog ciklusa i sanitizirani izlaz. Odvojeno čuvanje pojedinosti o neuspjehu sprječava izlaganje tijela uzvodne pogreške korisnicima.

<?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_theme_drafts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('status', 24)->default('pending');
            $table->string('source_url', 2048);
            $table->string('brand_name', 200)->nullable();
            $table->json('evidence')->nullable();
            $table->json('css_variables')->nullable();
            $table->string('failure_code', 64)->nullable();
            $table->text('failure_message')->nullable();
            $table->timestamp('approved_at')->nullable();
            $table->timestamps();
        });
    }

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

U app/Models/BrandThemeDraft.php dodajte pretvorbe za JSON i vremenske oznake:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class BrandThemeDraft extends Model
{
    protected function casts(): array
    {
        return [
            'evidence' => 'array',
            'css_variables' => 'array',
            'approved_at' => 'immutable_datetime',
        ];
    }
}

Provjerite API odgovor na granici

Dokumentirane kategorije su obvezne, ali njihovi ugniježđeni dokazi i dalje se trebaju tretirati kao nepouzdani JSON. Donji mapper ograničava dubinu i ukupnu veličinu, zahtijeva očekivane vrste najviše razine te CSS varijablama daje stroži tretman jer mogu na kraju ući u deklaraciju stila.

<?php

namespace App\Data;

use UnexpectedValueException;

final readonly class BrandKitData
{
    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(mixed $payload): self
    {
        if (! is_array($payload)) {
            throw new UnexpectedValueException('Response is not a JSON object.');
        }

        $requiredArrays = [
            'logos', 'colors', 'fonts', 'imagery',
            'social_profiles', 'css_variables',
        ];

        if (! isset($payload['brand_name'])
            || ! is_string($payload['brand_name'])
            || trim($payload['brand_name']) === ''
            || mb_strlen($payload['brand_name']) > 200) {
            throw new UnexpectedValueException('Invalid brand name.');
        }

        foreach ($requiredArrays as $field) {
            if (! array_key_exists($field, $payload) || ! is_array($payload[$field])) {
                throw new UnexpectedValueException("Invalid {$field} field.");
            }
        }

        $nodes = 0;
        foreach ($requiredArrays as $field) {
            self::assertBoundedJson($payload[$field], 0, $nodes);
        }

        $css = [];
        foreach ($payload['css_variables'] as $name => $value) {
            if (! is_string($name)
                || ! preg_match('/^--[a-zA-Z0-9_-]{1,80}$/', $name)
                || ! is_string($value)
                || mb_strlen($value) > 200
                || preg_match('/url\s*\(|expression\s*\(|[<>;{}]/i', $value)) {
                throw new UnexpectedValueException('Unsafe CSS variable.');
            }

            $css[$name] = $value;
        }

        return new self(
            trim($payload['brand_name']),
            $payload['logos'],
            $payload['colors'],
            $payload['fonts'],
            $payload['imagery'],
            $payload['social_profiles'],
            $css,
        );
    }

    private static function assertBoundedJson(
        mixed $value,
        int $depth,
        int &$nodes
    ): void {
        if ($depth > 6 || ++$nodes > 1000) {
            throw new UnexpectedValueException('Brand evidence is too large.');
        }

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

        if (! is_null($value)
            && ! is_string($value)
            && ! is_int($value)
            && ! is_float($value)
            && ! is_bool($value)) {
            throw new UnexpectedValueException('Unsupported evidence value.');
        }

        if (is_string($value) && mb_strlen($value) > 4096) {
            throw new UnexpectedValueException('Evidence value is too long.');
        }
    }
}

Ovo je provjera, a ne semantičko odobrenje. Sintaktički valjan URL logotipa i dalje može upućivati na neočekivani host, a naziv fonta možda nije licenciran za redistribuciju. Zadržite izbjegnute znakove u pretpregledima, prema zadanim postavkama ne posredujte resurse i zahtijevajte potvrdu korisnika prije objave.

Izgradite ograničeni API klijent svjestan ponovnih pokušaja

Izradite iznimku koja nosi stabilan razlog aplikacije, HTTP status, odgodu ponovnog pokušaja i oznaku mogućnosti ponavljanja. Zatim izolirajte sva ponašanja prijenosa u BrandKitClient.

<?php

namespace App\Exceptions;

use RuntimeException;

final class BrandKitApiException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly ?int $status = null,
        public readonly ?int $retryAfter = null,
        public readonly bool $retryable = false,
    ) {
        parent::__construct($reason);
    }
}
<?php

namespace App\Services;

use App\Data\BrandKitData;
use App\Exceptions\BrandKitApiException;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use UnexpectedValueException;

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

        if (! is_string($token) || $token === '') {
            throw new BrandKitApiException('configuration_error');
        }

        try {
            $response = Http::baseUrl(config('services.brand_kit.base_url'))
                ->withToken($token)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(3)
                ->timeout(25)
                ->retry(
                    [250, 750, 1500],
                    function (Exception $exception): bool {
                        if ($exception instanceof ConnectionException) {
                            return true;
                        }

                        return $exception instanceof RequestException
                            && in_array(
                                $exception->response->status(),
                                [429, 500, 502, 503, 504],
                                true
                            );
                    },
                    throw: false
                )
                ->post('/v1/extract-brand-kit', ['url' => $url]);
        } catch (ConnectionException) {
            throw new BrandKitApiException(
                'connection_failure',
                retryable: true
            );
        }

        if ($response->status() === 429) {
            $header = $response->header('Retry-After');
            $delay = ctype_digit((string) $header) ? (int) $header : 60;

            throw new BrandKitApiException(
                'rate_limited',
                429,
                min(max($delay, 30), 900),
                true
            );
        }

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

        if ($response->status() === 422) {
            throw new BrandKitApiException('request_rejected', 422);
        }

        if (! $response->successful()) {
            throw new BrandKitApiException(
                'upstream_failure',
                $response->status(),
                retryable: $response->serverError()
            );
        }

        try {
            return BrandKitData::fromApi($response->json());
        } catch (UnexpectedValueException $exception) {
            throw new BrandKitApiException(
                'invalid_response',
                $response->status()
            );
        }
    }
}

Klijent ponovno pokušava samo kod neuspjeha veze, ograničavanja prometa i odabranih pogrešaka poslužitelja. Neuspjesi autentifikacije i provjere su deterministički; njihovo ponovno pokušavanje troši kvotu i odgađa korisne povratne informacije. Vremenska ograničenja su ograničena, a tijelo odgovora nikada ne ulazi u zapisnik ni u poruku o pogrešci namijenjenu korisniku.

Stavite izdvajanje u red čekanja i izložite rutu za uvođenje korisnika

Posao je jedinstven po nacrtu, podržava odgođeni oporavak od ograničenja brzine i pohranjuje generičku završnu pogrešku. Za uobičajene prolazne neuspjehe Laravelova politika ponovnog pokušavanja reda čekanja pruža dulje odgađanje.

<?php

namespace App\Jobs;

use App\Exceptions\BrandKitApiException;
use App\Models\BrandThemeDraft;
use App\Services\BrandKitClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

final class ExtractBrandKit implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $tries = 5;
    public int $uniqueFor = 1800;
    public array $backoff = [60, 300, 900];

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

    public function uniqueId(): string
    {
        return (string) $this->draftId;
    }

    public function handle(BrandKitClient $client): void
    {
        $draft = BrandThemeDraft::findOrFail($this->draftId);

        if ($draft->status === 'completed') {
            return;
        }

        $draft->status = 'processing';
        $draft->save();

        try {
            $kit = $client->extract($draft->source_url);
        } catch (BrandKitApiException $exception) {
            Log::warning('Brand extraction failed', [
                'draft_id' => $draft->id,
                'source_host' => parse_url($draft->source_url, PHP_URL_HOST),
                'reason' => $exception->reason,
                'status' => $exception->status,
            ]);

            if ($exception->reason === 'rate_limited'
                && $this->attempts() < $this->tries) {
                $draft->status = 'retrying';
                $draft->save();
                $this->release($exception->retryAfter ?? 60);
                return;
            }

            if ($exception->retryable && $this->attempts() < $this->tries) {
                throw $exception;
            }

            $draft->status = 'failed';
            $draft->failure_code = $exception->reason;
            $draft->failure_message = 'Brand extraction could not be completed.';
            $draft->save();
            return;
        }

        $draft->brand_name = $kit->brandName;
        $draft->evidence = [
            'logos' => $kit->logos,
            'colors' => $kit->colors,
            'fonts' => $kit->fonts,
            'imagery' => $kit->imagery,
            'social_profiles' => $kit->socialProfiles,
        ];
        $draft->css_variables = $kit->cssVariables;
        $draft->status = 'completed';
        $draft->failure_code = null;
        $draft->failure_message = null;
        $draft->save();
    }

    public function failed(?Throwable $exception): void
    {
        BrandThemeDraft::whereKey($this->draftId)->update([
            'status' => 'failed',
            'failure_code' => 'retry_exhausted',
            'failure_message' => 'Brand extraction could not be completed.',
        ]);
    }
}

Kontroler odbacuje lokalne i privatne IP literale. U zrelom tijeku uvođenja korisnika također usporedite poslani host s već provjerenom domenom web-mjesta korisnika. Nemojte dopustiti da ova krajnja točka postane površina za slanje URL-ova opće namjene.

<?php

namespace App\Http\Controllers;

use App\Jobs\ExtractBrandKit;
use App\Models\BrandThemeDraft;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

final class OnboardingBrandDraftController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'url' => ['required', 'url:http,https', 'max:2048'],
        ]);

        $host = strtolower((string) parse_url($validated['url'], PHP_URL_HOST));
        $reservedName = $host === 'localhost' || str_ends_with($host, '.local');
        $privateIp = filter_var($host, FILTER_VALIDATE_IP)
            && ! filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            );

        if ($host === '' || $reservedName || $privateIp) {
            throw ValidationException::withMessages([
                'url' => 'Use an authorized public website URL.',
            ]);
        }

        $draft = new BrandThemeDraft();
        $draft->user_id = $request->user()->id;
        $draft->source_url = $validated['url'];
        $draft->status = 'pending';
        $draft->save();

        ExtractBrandKit::dispatch($draft->id)->afterCommit();

        return response()->json([
            'id' => $draft->id,
            'status' => $draft->status,
        ], 202);
    }
}

Registrirajte autentificiranu rutu s ograničenom stopom u routes/web.php:

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

Route::middleware(['auth', 'throttle:10,1'])->post(
    '/onboarding/brand-drafts',
    [OnboardingBrandDraftController::class, 'store']
);

Testirajte bez pozivanja stvarne usluge

Laravelov Http::fake() čini prijenos determinističkim. Ovaj funkcionalni test provjerava točan URL, Bearer autentifikaciju, JSON zahtjev, mapiranje domene i trajnu pohranu.

<?php

namespace Tests\Feature;

use App\Jobs\ExtractBrandKit;
use App\Models\BrandThemeDraft;
use App\Models\User;
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_stores_a_validated_theme_draft(): void
    {
        config(['services.brand_kit.token' => 'test-token']);

        Http::fake([
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
                => Http::response([
                    'brand_name' => 'Example Studio',
                    'logos' => ['https://customer.example/logo.svg'],
                    'colors' => ['#123456'],
                    'fonts' => ['Inter'],
                    'imagery' => [],
                    'social_profiles' => [],
                    'css_variables' => [
                        '--brand-primary' => '#123456',
                    ],
                ], 200),
        ]);

        $draft = new BrandThemeDraft();
        $draft->user_id = User::factory()->create()->id;
        $draft->source_url = 'https://customer.example';
        $draft->status = 'pending';
        $draft->save();

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

        $draft->refresh();

        $this->assertSame('completed', $draft->status);
        $this->assertSame('Example Studio', $draft->brand_name);
        $this->assertSame(
            '#123456',
            $draft->css_variables['--brand-primary']
        );

        Http::assertSent(fn ($request) =>
            $request->url() ===
                'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
            && $request->hasHeader(
                'Authorization',
                'Bearer test-token'
            )
            && $request->data() === [
                'url' => 'https://customer.example',
            ]
        );
    }
}

Dodajte usmjerene testove klijenta za neispravan odgovor, 401 koji se pokušava jednom, ponovno pokušani 503, ograničenu odgodu 429 i iznimku veze. Također testirajte odbijanje localhosta, privatnih IP literala, neautentificiranih zahtjeva i prevelikih URL-ova u kontroleru.

Sigurnost, vidljivost i implementacija

Nikada ne stavljajte token u JavaScript, fixture datoteke, snimke zaslona, poruke iznimki ni zapisnike. Redigirajte autorizacijska zaglavlja na svojoj platformi za vidljivost. Zabilježite identifikator nacrta, izvorni host, stabilni razlog neuspjeha, status, broj pokušaja i trajanje — ne token, puni odgovor ni niz upita URL-a.

Prikažite naziv brenda Bladeovim izbjegnutim izlazom. Nemojte pretvarati dokaze u sirovi HTML. Primjenjujte CSS varijable samo na izolirani pretpregled nakon provjere, po mogućnosti prema popisu dopuštenih podržanih naziva varijabli u vlasništvu proizvoda. Vraćeni URL-ovi logotipa, slika i društvenih mreža trebaju ostati inertne poveznice dok se zasebno ne provjere i odobre.

Tijekom implementacije pružite BRAND_KIT_TOKEN putem upravitelja tajni platforme, pokrenite php artisan migrate --force, zatim pokrenite php artisan config:cache. Pokrenite nadziranog radnika kao što je php artisan queue:work --tries=5 --timeout=60. Nakon rotacije tokena ili implementacije promjena klijenta pokrenite php artisan queue:restart kako bi dugotrajni radnici ponovno učitali konfiguraciju.

Postavite upozorenja za trajna povećanja u authentication_failed, rate_limited, invalid_response i retry_exhausted. Jedan ograničeni zahtjev je uobičajen; neuspjeh autentifikacije u cijeloj floti obično signalizira rotaciju tokena ili zastarjelu predmemoriranu konfiguraciju.

Uobičajeni neuspjesi

  • 401 ili 403: potvrdite aktivaciju plana, token vezan uz uslugu i predmemoriranu konfiguraciju. Nemojte naslijepo ponovno pokušavati.
  • 422: provjerite sadržava li tijelo valjan javni url i je li web-mjesto korisnika dostupno.
  • 429: poštujte Retry-After, zadržite nacrt i nastavite putem reda čekanja.
  • Vremenska ograničenja ili odgovori 5xx: koristite ograničene ponovne pokušaje i odgađanje reda čekanja; nikada ne držite zahtjev preglednika za uvođenje korisnika otvorenim.
  • Neispravan odgovor: sigurno zatvorite postupak. Nemojte sačuvati djelomičnu temu i istražite odstupanje ugovora bez zapisivanja cijelog korisnog tereta.
  • Nacrt nikada ne napreduje: potvrdite da radnik reda čekanja radi i pregledava isti red čekanja i okruženje kao web-aplikacija.

Završni kontrolni popis za provjeru

  • Račun i plan Free, Plus ili Pro su aktivni, a token usluge dolazi s ploče Service token na stranici dokumentacije.
  • Aplikacija šalje točno jedan JSON url na dokumentiranu POST krajnju točku koristeći Bearer autentifikaciju.
  • Vremenska ograničenja veze i odgovora su ograničena, a ponovno se pokušavaju samo prolazni neuspjesi.
  • Naziv brenda, logotipi, boje, fontovi, slike, profili na društvenim mrežama i CSS varijable provjeravaju se prije pohrane.
  • Udaljeni sadržaj ostaje neodobreni nacrt; nijedna oznaka ni resurs ne objavljuju se automatski.
  • Testovi koriste Http::fake() i nikada ne troše kvotu niti sadržavaju stvarni token.
  • Radnici, zapisnici, upozorenja, rotacija tajni i predmemorirana konfiguracija obuhvaćeni su postupcima implementacije.

Najbolja automatizacija uvođenja korisnika ne pretvara se da je izdvajanje prosudba. Uklanja rad s praznom stranicom, a ovlast za objavu zadržava kod korisnika. Tretirajući API odgovor kao dokaz, provodeći usku granicu i čineći stanja neuspjeha vidljivima, Laravel može pretvoriti postojeće web-mjesto u koristan nacrt odredišne stranice bez pretvaranja praktičnosti u sigurnosni prečac.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.