Vodiči

Laravel CRM Automation: Pre-fill Leads Instantly from Company Websites

Laravel CRM automatizacija: Trenutačno unaprijed popunite potencijalne klijente s web-mjesta tvrtki

Prodavač ne bi trebao kopirati naziv tvrtke, telefonski broj i podatke za kontakt iz pet kartica preglednika prije stvaranja jednog potencijalnog klijenta. Web-mjesto tvrtke već je koristan identifikator; CRM bi ga trebao pretvoriti u skicu koju prodavač može pregledati i spremiti.

Ovaj vodič izrađuje taj tijek rada u Laravelu i PHP-u 8.3. Prodavač unosi javno web-mjesto, aplikacija poziva podatkovnu uslugu Website to Company, mapira odgovor na strogoj granici aplikacije i ispunjava obrazac potencijalnog klijenta bez prepisivanja bilo čega što je prodavač već upisao.

Implementacija je sinkrona jer je prethodno popunjavanje interaktivna radnja. Red bi dodao anketiranje, zastarjele skice i više stanja pogreške bez poboljšanja ovog kratkog tijeka zahtjeva i odgovora. I dalje ćemo dodati ograničene vremenske rokove, selektivne ponovne pokušaje, strukturirane pogreške, testove, ograničavanje učestalosti i zapisivanje sigurno za produkciju.

Dobijte pristup i kopirajte token usluge

  1. Izradite račun na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.
  2. Otvorite stranicu podatkovne usluge Website to Company.
  3. Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.

Ova usluga zahtijeva token. Njegovo ponovno generiranje opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom: prvo ažurirajte produkcijsku tajnu, implementirajte ili osvježite predmemoriranu konfiguraciju, a zatim provjerite zahtjev. Nikada nemojte stavljati token u kontrolu izvornog koda, fixturee, snimke zaslona, poruke iznimki ili zapisnike aplikacije.

Potvrdite HTTP ugovor

Točan je zahtjev GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Autentikacija upotrebljava parametar upita token={serviceToken}, a ciljna se stranica navodi putem parametra upita website.

Testirajte vjerodajnicu iz pouzdane ljuske prije pisanja integracijskog koda:

curl --get 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
  --data-urlencode 'website=https://example.com' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN'

Usluga vraća podatke o tvrtki i kontaktu, uključujući company, contact, email, phone i people. Aplikacija mora validirati te vrijednosti umjesto da dopusti da se vanjski odgovor neispitan proširi domenom.

Izradite konfiguraciju Laravel projekta

Potreban vam je PHP 8.3 ili noviji, Composer i Laravel aplikacija s autentificiranim obrascem za potencijalnog klijenta. Za novu aplikaciju izradite projekt i pokrenite njegove početne migracije:

composer create-project laravel/laravel crm-prefill
cd crm-prefill
php artisan migrate

Dodajte namjenski unos u config/services.php. Zadržavanje i vjerodajnice i krajnje točke iza Laravel konfiguracije čini testove determinističkima i omogućuje ispravan rad naredbe config:cache.

<?php

return [
    // Existing service configuration...

    'website_company_data' => [
        'endpoint' => env(
            'WEBSITE_COMPANY_DATA_ENDPOINT',
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
        ),
        'token' => env('WEBSITE_COMPANY_DATA_TOKEN'),
    ],
];

Kopirani token pohranite u lokalnu datoteku .env projekta. U .env.example predajte samo rezervirano mjesto.

WEBSITE_COMPANY_DATA_ENDPOINT=https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
WEBSITE_COMPANY_DATA_TOKEN=YOUR_SERVICE_TOKEN

Dovršena integracija dodaje četiri glavna dijela: DTO odgovora, API uslugu, validiranu krajnju točku kontrolera i mali prilagodnik na strani preglednika za postojeći CRM obrazac. Nijedna obogaćena skica ne sprema se dok prodavač ne pošalje uobičajeni obrazac potencijalnog klijenta.

Mapirajte vanjski odgovor na granici

Izradite app/Data/CompanyData.php. Obrazac iz ovog primjera upotrebljava skalarna polja za tvrtku, kontakt, e-poštu i telefon, stoga se vrijednosti koje nisu nizovi znakova smatraju odstupanjem uzvodne sheme, umjesto da se nagađanjem pretvaraju u proizvoljan prikaz. People ostaje polje jer korisničko sučelje može prikazati više kandidata.

<?php

namespace App\Data;

use UnexpectedValueException;

final readonly class CompanyData
{
    public function __construct(
        public ?string $company,
        public ?string $contact,
        public ?string $email,
        public ?string $phone,
        public array $people,
    ) {}

    public static function fromApi(array $payload): self
    {
        $string = static function (string $key) use ($payload): ?string {
            if (!array_key_exists($key, $payload) || $payload[$key] === null) {
                return null;
            }

            if (!is_string($payload[$key])) {
                throw new UnexpectedValueException(
                    "Expected {$key} to be a string or null."
                );
            }

            $value = trim($payload[$key]);

            return $value === '' ? null : $value;
        };

        $people = $payload['people'] ?? [];

        if (!is_array($people)) {
            throw new UnexpectedValueException(
                'Expected people to be an array.'
            );
        }

        return new self(
            company: $string('company'),
            contact: $string('contact'),
            email: $string('email'),
            phone: $string('phone'),
            people: $people,
        );
    }

    public function toArray(): array
    {
        return [
            'company' => $this->company,
            'contact' => $this->contact,
            'email' => $this->email,
            'phone' => $this->phone,
            'people' => $this->people,
        ];
    }
}

Izradite HTTP uslugu svjesnu pogrešaka

Izradite app/Exceptions/CompanyDataException.php za pogreške koje kontroler može sigurno prevesti u javne odgovore:

<?php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

final class CompanyDataException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly int $httpStatus,
        string $message,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Sada izradite app/Services/WebsiteCompanyData.php. Upotrebljava Laravelov ugrađeni HTTP klijent, ograničava vrijeme povezivanja i ukupno vrijeme odgovora te ponovno pokušava samo neuspjela povezivanja, ograničenja učestalosti i odabrane prolazne pogreške poslužitelja. Pogreške autentikacije i validacije ne pokušavaju se ponovno.

<?php

namespace App\Services;

use App\Data\CompanyData;
use App\Exceptions\CompanyDataException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use LogicException;
use UnexpectedValueException;

final class WebsiteCompanyData
{
    public function extract(
        string $website,
        string $correlationId
    ): CompanyData {
        $endpoint = (string) config('services.website_company_data.endpoint');
        $token = (string) config('services.website_company_data.token');

        if ($token === '') {
            throw new LogicException(
                'WEBSITE_COMPANY_DATA_TOKEN is not configured.'
            );
        }

        $delayMicroseconds = 0;

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            if ($delayMicroseconds > 0) {
                usleep($delayMicroseconds);
            }

            try {
                $response = Http::acceptJson()
                    ->connectTimeout(3)
                    ->timeout(12)
                    ->get($endpoint, [
                        'website' => $website,
                        'token' => $token,
                    ]);
            } catch (ConnectionException $exception) {
                Log::warning('Company enrichment connection failure', [
                    'correlation_id' => $correlationId,
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);

                if ($attempt === 3) {
                    throw new CompanyDataException(
                        'upstream_unavailable',
                        503,
                        'Company enrichment is temporarily unavailable.',
                        $exception,
                    );
                }

                $delayMicroseconds = $attempt === 1 ? 200_000 : 600_000;
                continue;
            }

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

                if (!is_array($payload)) {
                    throw new CompanyDataException(
                        'invalid_response',
                        502,
                        'The enrichment service returned an invalid response.'
                    );
                }

                try {
                    return CompanyData::fromApi($payload);
                } catch (UnexpectedValueException $exception) {
                    throw new CompanyDataException(
                        'invalid_response',
                        502,
                        'The enrichment response did not match the expected schema.',
                        $exception,
                    );
                }
            }

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

            Log::warning('Company enrichment HTTP failure', [
                'correlation_id' => $correlationId,
                'attempt' => $attempt,
                'upstream_status' => $status,
                'retryable' => $retryable,
            ]);

            if ($retryable && $attempt < 3) {
                $delayMicroseconds = $attempt === 1 ? 200_000 : 600_000;

                if ($status === 429) {
                    $retryAfter = filter_var(
                        $response->header('Retry-After'),
                        FILTER_VALIDATE_INT
                    );

                    if ($retryAfter !== false) {
                        $delayMicroseconds = max(
                            $delayMicroseconds,
                            min($retryAfter, 2) * 1_000_000
                        );
                    }
                }

                continue;
            }

            throw match (true) {
                in_array($status, [401, 403], true) =>
                    new CompanyDataException(
                        'upstream_authentication',
                        502,
                        'Company enrichment is not configured correctly.'
                    ),
                in_array($status, [400, 422], true) =>
                    new CompanyDataException(
                        'website_rejected',
                        422,
                        'The website could not be accepted for enrichment.'
                    ),
                $status === 429 =>
                    new CompanyDataException(
                        'rate_limited',
                        429,
                        'The enrichment limit was reached. Try again shortly.'
                    ),
                default =>
                    new CompanyDataException(
                        'upstream_unavailable',
                        503,
                        'Company enrichment is temporarily unavailable.'
                    ),
            };
        }

        throw new LogicException('Unreachable enrichment state.');
    }
}

Maksimalan broj ponovnih pokušaja namjerno je malen. Dulje oluje ponovnih pokušaja povećavaju latenciju odgovora i troše više kvote dok je ovisnost već nezdrava. Ograničeno rukovanje zaglavljem Retry-After poštuje kratke prozore ograničenja učestalosti bez dopuštanja da uzvodno zaglavlje neograničeno zadrži PHP radnik.

Izložite autentificiranu krajnju točku za prethodno popunjavanje

Izradite app/Http/Requests/PrefillLeadRequest.php:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class PrefillLeadRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'website' => [
                'required',
                'string',
                'max:2048',
                'url:http,https',
                function (string $attribute, mixed $value, $fail): void {
                    $host = parse_url((string) $value, PHP_URL_HOST);

                    if (!is_string($host) || strtolower($host) === 'localhost') {
                        $fail('The website must have a public hostname.');
                        return;
                    }

                    if (
                        filter_var($host, FILTER_VALIDATE_IP)
                        && !filter_var(
                            $host,
                            FILTER_VALIDATE_IP,
                            FILTER_FLAG_NO_PRIV_RANGE |
                            FILTER_FLAG_NO_RES_RANGE
                        )
                    ) {
                        $fail('Private and reserved IP addresses are not allowed.');
                    }
                },
            ],
        ];
    }
}

Izradite app/Http/Controllers/LeadPrefillController.php:

<?php

namespace App\Http\Controllers;

use App\Exceptions\CompanyDataException;
use App\Http\Requests\PrefillLeadRequest;
use App\Services\WebsiteCompanyData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

final class LeadPrefillController extends Controller
{
    public function __invoke(
        PrefillLeadRequest $request,
        WebsiteCompanyData $service
    ): JsonResponse {
        $correlationId = (string) Str::uuid();

        try {
            $company = $service->extract(
                $request->string('website')->toString(),
                $correlationId
            );

            return response()->json([
                'data' => $company->toArray(),
                'meta' => ['correlation_id' => $correlationId],
            ]);
        } catch (CompanyDataException $exception) {
            Log::notice('Lead prefill failed', [
                'correlation_id' => $correlationId,
                'reason' => $exception->reason,
            ]);

            return response()->json([
                'error' => [
                    'code' => $exception->reason,
                    'message' => $exception->getMessage(),
                    'correlation_id' => $correlationId,
                ],
            ], $exception->httpStatus);
        }
    }
}

Registrirajte rutu u routes/web.php. Autentikacija, CSRF zaštita i ograničavanje učestalosti po korisniku štite token usluge i kvotu paketa:

<?php

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

Route::post('/leads/prefill', LeadPrefillController::class)
    ->middleware(['auth', 'throttle:10,1'])
    ->name('leads.prefill');

Ispunite CRM obrazac bez uništavanja korisničkog unosa

Dodajte sljedeće u resources/js/lead-prefill.js i uvezite ga iz ulazne točke aplikacije. Postojeći obrazac treba imati data-lead-form, polje @csrf te unose nazvane website, company, contact, email i phone.

const form = document.querySelector('[data-lead-form]');

if (form) {
    const website = form.elements.website;

    website.addEventListener('blur', async () => {
        if (!website.value.trim()) return;

        const controller = new AbortController();
        const timer = window.setTimeout(() => controller.abort(), 45_000);

        try {
            const response = await fetch('/leads/prefill', {
                method: 'POST',
                credentials: 'same-origin',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': form.elements._token.value,
                },
                body: JSON.stringify({ website: website.value.trim() }),
                signal: controller.signal,
            });

            const payload = await response.json();

            if (!response.ok) {
                throw new Error(
                    payload.error?.message ?? 'Lead prefill failed.'
                );
            }

            for (const field of ['company', 'contact', 'email', 'phone']) {
                const value = payload.data[field];

                if (
                    typeof value === 'string' &&
                    form.elements[field] &&
                    !form.elements[field].value
                ) {
                    form.elements[field].value = value;
                }
            }

            form.dispatchEvent(new CustomEvent('company-people-loaded', {
                detail: payload.data.people,
            }));
        } catch (error) {
            form.dispatchEvent(new CustomEvent('company-prefill-failed', {
                detail: error,
            }));
        } finally {
            window.clearTimeout(timer);
        }
    });
}

Popunjavaju se samo prazna polja. To malo pravilo je važno: vanjsko obogaćivanje je prijedlog, dok prodavač ostaje autoritet nad potencijalnim klijentom koji se stvara. Događaj company-people-loaded također ostavlja izbore prikaza — poput birača kontakta — CRM sučelju umjesto da ih povezuje s transportnim kodom.

Testirajte integraciju bez pozivanja usluge

Laravelov Http::fake() pruža deterministički transport. Izradite tests/Feature/WebsiteCompanyDataTest.php:

<?php

namespace Tests\Feature;

use App\Exceptions\CompanyDataException;
use App\Services\WebsiteCompanyData;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class WebsiteCompanyDataTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        config()->set(
            'services.website_company_data.endpoint',
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
        );
        config()->set(
            'services.website_company_data.token',
            'test-service-token'
        );
    }

    public function test_it_maps_company_data_and_sends_the_contract(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([
                'company' => 'Example Company',
                'contact' => 'Sales',
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['display' => 'Primary contact']],
            ], 200),
        ]);

        $result = app(WebsiteCompanyData::class)->extract(
            'https://example.com',
            'test-correlation-id'
        );

        $this->assertSame('Example Company', $result->company);
        $this->assertCount(1, $result->people);

        Http::assertSent(function ($request): bool {
            return $request->method() === 'GET'
                && $request['website'] === 'https://example.com'
                && $request['token'] === 'test-service-token';
        });
    }

    public function test_authentication_failure_is_not_retried(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([], 401),
        ]);

        try {
            app(WebsiteCompanyData::class)->extract(
                'https://example.com',
                'test-correlation-id'
            );

            $this->fail('Expected CompanyDataException.');
        } catch (CompanyDataException $exception) {
            $this->assertSame(
                'upstream_authentication',
                $exception->reason
            );
        }

        Http::assertSentCount(1);
    }

    public function test_it_rejects_an_unexpected_response_shape(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([
                'company' => ['unexpected' => 'object'],
                'people' => [],
            ], 200),
        ]);

        $this->expectException(CompanyDataException::class);

        app(WebsiteCompanyData::class)->extract(
            'https://example.com',
            'test-correlation-id'
        );
    }
}

Pokrenite skup testova naredbom php artisan test. Ovi testovi provjeravaju točnu metodu, parametre vezane uz krajnju točku, smještaj tokena, mapiranje odgovora i važno pravilo da neuspjeh autentikacije ne prima ponovne pokušaje.

Sigurnost, nadzor i implementacija

  • Zaštitite krajnju točku: zadržite autentikaciju, CSRF validaciju, autorizaciju prikladnu za stvaranje potencijalnih klijenata i ograničavanje učestalosti. Anonimni posjetitelji ne smiju moći trošiti kvotu usluge.
  • Redigirajte vjerodajnice: budući da je obvezni mehanizam autentikacije parametar upita, nikada nemojte zapisivati potpuni odlazni URL. Klasa usluge zapisuje samo status, pokušaj, razlog i lokalno generirani ID korelacije.
  • Ograničite unos: prihvaćajte samo HTTP ili HTTPS URL-ove, ograničite njihovu duljinu i odbijte očite lokalne, privatne i rezervirane ciljeve.
  • Mjerite korisne signale: pratite latenciju, odgovore ograničenja učestalosti, prolazne neuspjehe, nevaljane sheme i neuspjehe autentikacije. Upozoravajte na trajne promjene, a ne na jedno izolirano loše web-mjesto.
  • Sačuvajte pregled: obogaćivanje treba prethodno popuniti skicu, a ne tiho stvoriti ili prepisati potencijalnog klijenta. Čovjek bi trebao potvrditi rezultat prije uobičajene operacije spremanja.

U produkciji umetnite WEBSITE_COMPANY_DATA_TOKEN putem upravitelja tajni platforme, dopustite odlazni HTTPS pristup prema ai.mihajlo.mk i ponovno izgradite Laravelovu predmemoriju konfiguracije:

php artisan optimize:clear
php artisan config:cache
php artisan test

Nedostajući token obično ukazuje na zastarjelu predmemoriranu konfiguraciju. Status 401 ili 403 upućuje na nevaljan, opozvan ili nepravilno implementiran token usluge. Status 422 znači da je poslano web-mjesto odbijeno. Status 429 ukazuje na kvotu ili ograničavanje učestalosti; integracija nakratko ponovno pokušava, a zatim vraća strukturirani neuspjeh. Ponovljene odgovore 5xx ili vremenska ograničenja povezivanja treba tretirati kao probleme dostupnosti ovisnosti, a ne kao razloge za slanje neograničenih ponovnih pokušaja.

Završni kontrolni popis za provjeru

  • Prodavač mora biti autentificiran i ovlašten za stvaranje potencijalnih klijenata.
  • Unos valjanog web-mjesta tvrtke pokreće točno jednu interakciju prethodnog popunjavanja.
  • Zahtjev upotrebljava točnu GET krajnju točku s parametrima upita website i token.
  • Tvrtka, kontakt, e-pošta, telefon i people mapiraju se na API granici.
  • Postojeće vrijednosti obrasca nikada se ne prepisuju.
  • Neispravni odgovori, vremenska ograničenja, neuspjesi autentikacije, ograničenja učestalosti i prekidi rada uzvodne usluge stvaraju strukturirana stanja neuspjeha.
  • Nijedan token ni potpuni autentificirani URL ne pojavljuje se u zapisnicima, testovima, kontroli izvornog koda ni odgovorima preglednika.
  • Automatizirani testovi prolaze nakon što se produkcijska konfiguracija predmemorira.

Najvrjedniji dio ove značajke nije to što uklanja nekoliko pritisaka tipki. Ona obrazac potencijalnog klijenta pretvara iz prazne administrativne obveze u skicu koja se može pregledati. Uz usku API granicu, oprezne ponovne pokušaje i ljudsku potvrdu, jedno web-mjesto tvrtke postaje dovoljno za početak korisnog CRM rada bez pretvaranja podataka obogaćivanja u neupitnu istinu.

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.