Туториали

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

Laravel CRM автоматизација: Инстантно претходно пополнување на лидови од веб-страници на компании

Продавачот не треба да копира име на компанија, телефонски број и податоци за контакт од пет јазичиња во прелистувачот пред да создаде еден потенцијален клиент. Веб-страницата на компанијата веќе е корисен идентификатор; CRM-системот треба да ја претвори во нацрт што продавачот може да го прегледа и зачува.

Овој туторијал го гради тој работен тек во Laravel и PHP 8.3. Продавачот внесува јавна веб-страница, апликацијата ја повикува услугата за податоци Website to Company, го мапира одговорот на строга граница на апликацијата и ја пополнува формата за потенцијален клиент без да пребрише нешто што продавачот веќе го внел.

Имплементацијата е синхрона бидејќи претпополнувањето е интерактивна дејство. Редицата би додала анкетирање, застарени нацрти и повеќе состојби на неуспех без да го подобри овој краток работен тек на барање и одговор. Сепак, ќе додадеме ограничени временски ограничувања, селективни повторни обиди, структурирани грешки, тестови, ограничување на барања и логирање безбедно за продукција.

Добијте пристап и копирајте го токенот на услугата

  1. Создадете сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.
  2. Отворете ја страницата на услугата Website to Company data.
  3. Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација за услугата.
  5. Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата што е прикажан таму.

Оваа услуга бара токен. Неговото повторно генерирање го поништува претходно активниот токен, затоа координирајте ја ротацијата со распоредувањето: прво ажурирајте ја тајната во продукција, распоредете или освежете ја кешираната конфигурација, а потоа проверете барање. Никогаш не ставајте го токенот во изворна контрола, фикстури, слики од екранот, пораки за исклучоци или логови на апликацијата.

Потврдете го HTTP-договорот

Точното барање е GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Автентикацијата го користи параметарот за барање token={serviceToken}, а целната страница се доставува преку параметарот за барање website.

Тестирајте ги акредитивите од доверлива школка пред да напишете интеграциски код:

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'

Услугата враќа информации за компанијата и контактот, вклучувајќи company, contact, email, phone и people. Апликацијата мора да ги валидира тие вредности наместо да дозволи надворешен одговор непрегледан да се прошири низ доменот.

Создадете ја конфигурацијата на Laravel проектот

Ви треба PHP 8.3 или понов, Composer и Laravel апликација со автентицирана форма за потенцијален клиент. За нова апликација, создадете го проектот и извршете ги неговите почетни миграции:

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

Додајте посебен запис во config/services.php. Чувањето и на акредитивот и на крајната точка зад Laravel конфигурација ги прави тестовите детерминистички и овозможува 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'),
    ],
];

Зачувајте го копираниот токен во локалната датотека .env на проектот. Во .env.example зачувајте само местоимател.

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

Завршената интеграција додава четири главни делови: DTO за одговор, API услуга, валидирана контролерска крајна точка и мал адаптер на страната на прелистувачот за постоечката CRM форма. Ниту еден збогатен нацрт не се зачувува додека продавачот не ја поднесе вообичаената форма за потенцијален клиент.

Мапирајте го надворешниот одговор на границата

Создадете app/Data/CompanyData.php. Формата во овој пример користи скаларни внесови за компанија, контакт, е-пошта и телефон, па не-низовите вредности се третираат како отстапување во шемата од нагорниот извор наместо да се претпоставуваат во произволна претстава. People остануваат низа бидејќи интерфејсот може да прикаже повеќе кандидати.

<?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,
        ];
    }
}

Изградете HTTP услуга свесна за неуспеси

Создадете app/Exceptions/CompanyDataException.php за грешки што контролерот може безбедно да ги преведе во јавни одговори:

<?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);
    }
}

Сега создадете app/Services/WebsiteCompanyData.php. Таа го користи вградениот HTTP клиент на Laravel, го ограничува времето за поврзување и вкупното време за одговор и повторува само неуспеси при поврзување, ограничувања на стапка и избрани минливи неуспеси на серверот. Не се повторуваат неуспесите на автентикација и валидација.

<?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.');
    }
}

Максималниот број повторни обиди намерно е мал. Подолгите бури од повторни обиди ја зголемуваат латентноста на одговорот и трошат повеќе квота додека зависноста веќе е нездрава. Ограниченото ракување со Retry-After ги почитува кратките прозорци за ограничување на стапката без да дозволи нагорен заглавие да задржи PHP работник на неодредено време.

Изложете автентицирана крајна точка за претпополнување

Создадете 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.');
                    }
                },
            ],
        ];
    }
}

Создадете 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);
        }
    }
}

Регистрирајте ја рутата во routes/web.php. Автентикацијата, CSRF-заштитата и ограничувањето по корисник ги заштитуваат токенот на услугата и квотата на планот:

<?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');

Пополнете ја CRM формата без да го уништите внесот на корисникот

Додајте го следново во resources/js/lead-prefill.js и увезете го од влезната точка на апликацијата. Постоечката форма треба да има data-lead-form, поле @csrf и внесови именувани website, company, contact, email и 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);
        }
    });
}

Се пополнуваат само празни полиња. Тоа мало правило е важно: надворешното збогатување е предлог, додека продавачот останува авторитетот за потенцијалниот клиент што се создава. Настанот company-people-loaded исто така ги остава изборите за презентација — како избирач на контакт — на CRM-интерфејсот, наместо да ги поврзува со транспортниот код.

Тестирајте ја интеграцијата без да ја повикате услугата

Http::fake() на Laravel обезбедува детерминистички транспорт. Создадете 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'
        );
    }
}

Извршете го пакетот со php artisan test. Овие тестови ги проверуваат точниот метод, параметрите ограничени на крајната точка, поставувањето на токенот, мапирањето на одговорот и важната правило дека неуспех на автентикација не добива повторни обиди.

Безбедност, набљудливост и распоредување

  • Заштитете ја крајната точка: задржете автентикација, CSRF-валидација, овластување соодветно за создавање потенцијални клиенти и ограничување на барања. Анонимните посетители не смеат да можат да ја трошат квотата на услугата.
  • Редактирајте ги акредитивите: бидејќи потребниот механизам за автентикација е параметар за барање, никогаш не го логирајте целосниот појдовен URL. Класата за услугата логира само статус, обид, причина и локално генериран ID за корелација.
  • Ограничете го внесот: прифаќајте само HTTP или HTTPS URL-адреси, ограничете ја нивната должина и отфрлете очигледни локални, приватни и резервирани цели.
  • Мерете корисни сигнали: следете латентност, одговори за ограничување на стапката, минливи неуспеси, невалидни шеми и неуспеси на автентикација. Алармирајте при трајни промени, а не за една изолирана лоша веб-страница.
  • Зачувајте го прегледот: збогатувањето треба да претпополни нацрт, а не тивко да создаде или пребрише потенцијален клиент. Човек треба да го потврди резултатот пред вообичаената операција за зачувување.

Во продукција, внесете го WEBSITE_COMPANY_DATA_TOKEN преку управувачот со тајни на платформата, дозволете излезен HTTPS-пристап до ai.mihajlo.mk и повторно изградете го кешот за конфигурација на Laravel:

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

Недостасувачки токен обично укажува на застарена кеширана конфигурација. 401 или 403 укажува на невалиден, поништен или неправилно распоредeн токен на услугата. 422 значи дека поднесената веб-страница била одбиена. 429 укажува на квота или ограничување на стапка; интеграцијата кратко повторува, а потоа враќа структурирана состојба на неуспех. Повторените 5xx одговори или временски ограничувања на поврзување треба да се третираат како проблеми со достапноста на зависноста, а не како причина за поднесување неограничени повторни обиди.

Конечна контролна листа за верификација

  • Продавачот мора да биде автентициран и овластен да создава потенцијални клиенти.
  • Внесувањето важечка веб-страница на компанија активира точно една интеракција за претпополнување.
  • Барањето ја користи точната GET крајна точка со параметрите за барање website и token.
  • Компанијата, контактот, е-поштата, телефонот и луѓето се мапираат на API-границата.
  • Постоечките вредности во формата никогаш не се пребришуваат.
  • Неправилно обликувани одговори, временски ограничувања, неуспеси на автентикација, ограничувања на стапка и прекини на нагорниот извор создаваат структурирани состојби на неуспех.
  • Ниту еден токен или целосен автентициран URL не се појавува во логови, тестови, изворна контрола или одговори во прелистувачот.
  • Автоматизираните тестови поминуваат откако продукциската конфигурација е кеширана.

Највредниот дел од оваа функционалност не е тоа што отстранува неколку притискања на тастатурата. Таа ја менува формата за потенцијален клиент од празна административна обврска во нацрт што може да се прегледа. Со тесна API-граница, внимателни повторни обиди и човечка потврда, една веб-страница на компанија станува доволна за да се започне корисна CRM-работа без податоците за збогатување да се претворат во непроверена вистина.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.