Tutorials

Laravel: Transform Website Lists into AI-Powered Contact Research

Laravel: Transform Website Lists into AI-Powered Contact Research

A spreadsheet full of company websites looks like data, but it is really a queue of unanswered questions. Who is behind each site? Which contact details are publicly available? Which rows succeeded, which failed, and which need human judgment?

This tutorial builds a production-oriented Laravel workflow that imports website URLs from CSV, processes them asynchronously through a website-to-company data service, and presents the structured results in an authenticated review screen. The important design choice is that enrichment is treated as fallible background work, not as an HTTP request that must succeed while a user waits.

Get service access before writing integration code

First, register an account, or use the sign-in page if you already have one.

Open the Website to Company data service page. Choose an available Free, Plus, or Pro plan and complete its activation. Then open the official service documentation, locate the Service token panel, and copy the service-scoped token.

This service is not token-free: every request needs that credential. Regenerating the token revokes the previously active token, so token rotation must include updating the application environment and restarting workers that cache configuration.

The exact integration is an HTTP GET request to https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Authentication uses the token query parameter, while the company site is supplied as website. Before building the application, verify access with a placeholder credential:

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

Do not place the real token in source control, shell history shared with others, screenshots, logs, or test fixtures. Put it in the deployment environment or the uncommitted Laravel .env file:

MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Expose it to the application through config/services.php. Keeping environment access inside configuration is important because Laravel configuration may be cached in production.

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

Architecture: import, enrich, then review

The input spreadsheet should be exported as UTF-8 CSV with a header named website. An Artisan command validates and canonicalizes each URL, stores one research record, and dispatches a queue job. The job calls a dedicated API client, maps the response at the application boundary, and persists either structured data or a structured failure state. An authenticated controller displays the records for review.

The relevant files are app/Console/Commands/ImportCompanyWebsites.php, app/Jobs/ResearchWebsite.php, app/Services/WebsiteCompanyClient.php, app/Data/CompanyProfile.php, app/Models/ContactResearch.php, and app/Http/Controllers/ContactResearchController.php.

One queued job per website costs more machinery than a synchronous loop, but it isolates slow or rate-limited calls, supports bounded retries, and prevents one broken row from abandoning an entire spreadsheet.

Persist both results and failure states

Do not assume that fields such as company or people always have one undocumented internal shape. The supplied contract guarantees those field names, not every nested value. JSON columns preserve the defensively normalized boundary representation.

<?php
// database/migrations/xxxx_xx_xx_create_contact_research_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('contact_research', function (Blueprint $table): void {
            $table->id();
            $table->text('website');
            $table->char('website_hash', 64)->unique();
            $table->string('status', 20)->index();
            $table->json('company')->nullable();
            $table->json('contact')->nullable();
            $table->json('email')->nullable();
            $table->json('phone')->nullable();
            $table->json('people')->nullable();
            $table->string('failure_code', 40)->nullable();
            $table->text('failure_detail')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('contact_research');
    }
};
<?php
// app/Models/ContactResearch.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class ContactResearch extends Model
{
    protected $table = 'contact_research';

    protected $fillable = [
        'website', 'website_hash', 'status', 'company', 'contact',
        'email', 'phone', 'people', 'failure_code', 'failure_detail',
    ];

    protected function casts(): array
    {
        return [
            'company' => 'array',
            'contact' => 'array',
            'email' => 'array',
            'phone' => 'array',
            'people' => 'array',
        ];
    }
}

Map the remote response at one boundary

The API client should be the only application component that understands remote field names, HTTP statuses, timeouts, and authentication. The DTO below converts each promised field into an array. Scalars become a named value, arrays retain their keys, and missing or unusable values become empty arrays.

<?php
// app/Data/CompanyProfile.php

namespace App\Data;

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

    public static function fromApi(array $payload): self
    {
        $field = static function (mixed $value): array {
            if (is_array($value)) {
                return $value;
            }

            if (is_string($value) || is_int($value)
                || is_float($value) || is_bool($value)) {
                return ['value' => $value];
            }

            return [];
        };

        return new self(
            $field($payload['company'] ?? null),
            $field($payload['contact'] ?? null),
            $field($payload['email'] ?? null),
            $field($payload['phone'] ?? null),
            $field($payload['people'] ?? null),
        );
    }

    public function toDatabase(): array
    {
        return get_object_vars($this);
    }
}

Use short connection and response deadlines. Authentication and validation failures are deterministic and must not be retried. Connection failures and server errors receive one immediate retry; longer recovery belongs to the queue. A rate limit is handed directly to the queue, optionally respecting a numeric Retry-After header.

<?php
// app/Services/WebsiteCompanyClient.php

namespace App\Services;

use App\Data\CompanyProfile;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class ApiFailure extends RuntimeException
{
    public function __construct(
        public readonly string $failureCode,
        public readonly bool $retryable,
        public readonly ?int $retryAfter = null,
        string $message = 'Website research failed',
    ) {
        parent::__construct($message);
    }
}

final class WebsiteCompanyClient
{
    public function extract(string $website): CompanyProfile
    {
        $token = (string) config('services.website_company.token');
        $endpoint = (string) config('services.website_company.endpoint');

        if ($token === '') {
            throw new ApiFailure('configuration', false, null, 'Service token is missing');
        }

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(5)
                    ->timeout(20)
                    ->get($endpoint, [
                        'token' => $token,
                        'website' => $website,
                    ]);
            } catch (ConnectionException $exception) {
                if ($attempt === 1) {
                    usleep(250_000);
                    continue;
                }

                throw new ApiFailure('transport', true, null, $exception->getMessage());
            }

            $status = $response->status();

            if (in_array($status, [401, 403], true)) {
                throw new ApiFailure('authentication', false);
            }

            if (in_array($status, [400, 422], true)) {
                throw new ApiFailure('invalid_request', false);
            }

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

                throw new ApiFailure('rate_limited', true, $delay);
            }

            if ($status >= 500 && $attempt === 1) {
                usleep(250_000);
                continue;
            }

            if ($response->failed()) {
                throw new ApiFailure('upstream_http', $status >= 500);
            }

            $payload = $response->json();

            if (! is_array($payload)) {
                throw new ApiFailure('invalid_response', false);
            }

            return CompanyProfile::fromApi($payload);
        }

        throw new ApiFailure('upstream_http', true);
    }
}

Import the spreadsheet without duplicating work

The importer reduces each entry to its HTTP or HTTPS origin. That makes duplicate detection predictable and avoids forwarding paths, fragments, credentials, or query strings. It also rejects localhost and literal private or reserved IP addresses. If CSV uploads are exposed through a browser, add authorization, file-size limits, storage isolation, and stricter organization-specific domain rules.

<?php
// app/Console/Commands/ImportCompanyWebsites.php

namespace App\Console\Commands;

use App\Jobs\ResearchWebsite;
use App\Models\ContactResearch;
use Illuminate\Console\Command;

final class ImportCompanyWebsites extends Command
{
    protected $signature = 'contacts:import {file}';
    protected $description = 'Queue company websites from a UTF-8 CSV file';

    public function handle(): int
    {
        $handle = fopen((string) $this->argument('file'), 'rb');

        if ($handle === false || ($header = fgetcsv($handle)) === false) {
            $this->error('The CSV cannot be opened or has no header.');
            return self::FAILURE;
        }

        $header = array_map(
            fn ($value) => strtolower(trim((string) $value, "\xEF\xBB\xBF \t\n\r\0\x0B")),
            $header
        );
        $index = array_search('website', $header, true);

        if ($index === false) {
            fclose($handle);
            $this->error('A website header is required.');
            return self::FAILURE;
        }

        $queued = 0;

        while (($row = fgetcsv($handle)) !== false) {
            $website = $this->origin((string) ($row[$index] ?? ''));

            if ($website === null) {
                $this->warn('Skipped an invalid website value.');
                continue;
            }

            $record = ContactResearch::firstOrCreate(
                ['website_hash' => hash('sha256', $website)],
                ['website' => $website, 'status' => 'pending'],
            );

            if ($record->wasRecentlyCreated || $record->status === 'failed') {
                $record->update([
                    'status' => 'pending',
                    'failure_code' => null,
                    'failure_detail' => null,
                ]);
                ResearchWebsite::dispatch($record->id);
                $queued++;
            }
        }

        fclose($handle);
        $this->info("Queued {$queued} website(s).");

        return self::SUCCESS;
    }

    private function origin(string $input): ?string
    {
        $input = trim($input);

        if ($input !== '' && ! str_contains($input, '://')) {
            $input = 'https://'.$input;
        }

        if (filter_var($input, FILTER_VALIDATE_URL) === false) {
            return null;
        }

        $parts = parse_url($input);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower((string) ($parts['host'] ?? ''));

        if (! in_array($scheme, ['http', 'https'], true)
            || $host === '' || isset($parts['user']) || isset($parts['pass'])
            || $host === 'localhost' || str_ends_with($host, '.local')) {
            return null;
        }

        if (filter_var($host, FILTER_VALIDATE_IP)
            && ! filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            )) {
            return null;
        }

        $port = isset($parts['port']) ? ':'.$parts['port'] : '';

        return "{$scheme}://{$host}{$port}";
    }
}

Run enrichment as a bounded queue job

The job records only a research identifier, so queued payloads contain neither the service token nor a stale model snapshot. Rate limits release the job with a bounded delay. Other transient failures use queue backoff. Permanent failures remain reviewable without poisoning the queue.

<?php
// app/Jobs/ResearchWebsite.php

namespace App\Jobs;

use App\Models\ContactResearch;
use App\Services\ApiFailure;
use App\Services\WebsiteCompanyClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

final class ResearchWebsite implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;

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

    public function backoff(): array
    {
        return [60, 300, 900];
    }

    public function handle(WebsiteCompanyClient $client): void
    {
        $record = ContactResearch::findOrFail($this->researchId);

        if ($record->status === 'complete') {
            return;
        }

        $record->update(['status' => 'processing']);

        try {
            $profile = $client->extract($record->website);

            $record->update($profile->toDatabase() + [
                'status' => 'complete',
                'failure_code' => null,
                'failure_detail' => null,
            ]);
        } catch (ApiFailure $failure) {
            $record->update([
                'status' => $failure->retryable ? 'pending' : 'failed',
                'failure_code' => $failure->failureCode,
                'failure_detail' => $failure->getMessage(),
            ]);

            Log::warning('Website research request failed', [
                'research_id' => $record->id,
                'host' => parse_url($record->website, PHP_URL_HOST),
                'failure_code' => $failure->failureCode,
                'retryable' => $failure->retryable,
            ]);

            if ($failure->retryable && $failure->retryAfter !== null) {
                $this->release($failure->retryAfter);
                return;
            }

            if ($failure->retryable) {
                throw $failure;
            }
        }
    }

    public function failed(?Throwable $exception): void
    {
        ContactResearch::whereKey($this->researchId)->update([
            'status' => 'failed',
            'failure_code' => 'retries_exhausted',
            'failure_detail' => 'Transient failure persisted after bounded retries.',
        ]);
    }
}

Add an authenticated review screen

The controller deliberately returns stored data rather than contacting the service during page rendering. Place the route behind the application’s existing authentication system so contact research is not published accidentally.

<?php
// app/Http/Controllers/ContactResearchController.php

namespace App\Http\Controllers;

use App\Models\ContactResearch;
use Illuminate\View\View;

final class ContactResearchController extends Controller
{
    public function index(): View
    {
        return view('contact-research.index', [
            'rows' => ContactResearch::query()
                ->latest('updated_at')
                ->paginate(50),
        ]);
    }
}

// routes/web.php
use App\Http\Controllers\ContactResearchController;
use Illuminate\Support\Facades\Route;

Route::get('/contact-research', [ContactResearchController::class, 'index'])
    ->middleware('auth')
    ->name('contact-research.index');
<!-- resources/views/contact-research/index.blade.php -->
<h2>Contact research</h2>

<ol>
@forelse ($rows as $row)
    <li>
        <p><strong>{{ $row->website }}</strong>: {{ $row->status }}</p>
        <p>Company: {{ json_encode($row->company, JSON_UNESCAPED_SLASHES) }}</p>
        <p>Contact: {{ json_encode($row->contact, JSON_UNESCAPED_SLASHES) }}</p>
        <p>Email: {{ json_encode($row->email, JSON_UNESCAPED_SLASHES) }}</p>
        <p>Phone: {{ json_encode($row->phone, JSON_UNESCAPED_SLASHES) }}</p>
        <p>People: {{ json_encode($row->people, JSON_UNESCAPED_SLASHES) }}</p>
        @if ($row->failure_code)
            <p>Failure: {{ $row->failure_code }}</p>
        @endif
    </li>
@empty
    <li>No research has been imported.</li>
@endforelse
</ol>

{{ $rows->links() }}

Test the boundary without making real requests

Laravel’s HTTP fake makes response mapping and retry policy deterministic. These tests prove that all five contracted fields are mapped and that an authentication failure is not retried.

<?php
// tests/Unit/WebsiteCompanyClientTest.php

namespace Tests\Unit;

use App\Services\ApiFailure;
use App\Services\WebsiteCompanyClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

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

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

    public function test_it_maps_the_contracted_fields(): void
    {
        Http::fake([
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract*'
                => Http::response([
                    'company' => ['name' => 'Example Ltd'],
                    'contact' => ['page' => '/contact'],
                    'email' => '[email protected]',
                    'phone' => null,
                    'people' => [['name' => 'Alex']],
                ], 200),
        ]);

        $profile = app(WebsiteCompanyClient::class)
            ->extract('https://example.com');

        $this->assertSame(['name' => 'Example Ltd'], $profile->company);
        $this->assertSame(['value' => '[email protected]'], $profile->email);
        $this->assertSame([], $profile->phone);
        $this->assertSame([['name' => 'Alex']], $profile->people);

        Http::assertSentCount(1);
    }

    public function test_it_does_not_retry_authentication_failures(): void
    {
        Http::fake(fn () => Http::response([], 401));

        try {
            app(WebsiteCompanyClient::class)->extract('https://example.com');
            $this->fail('Expected an API failure.');
        } catch (ApiFailure $failure) {
            $this->assertSame('authentication', $failure->failureCode);
            $this->assertFalse($failure->retryable);
        }

        Http::assertSentCount(1);
    }
}

Deploy and operate the workflow

Apply migrations, cache configuration after injecting the real token, and restart long-lived workers whenever configuration or code changes. Ensure the database queue’s jobs and failed-jobs migrations exist in the application.

php artisan test
php artisan migrate --force
php artisan config:cache
php artisan queue:restart

php artisan contacts:import storage/app/imports/companies.csv
php artisan queue:work --queue=default --timeout=60 --tries=4 --max-time=3600

Run the worker under a process supervisor rather than an interactive shell. Monitor queue depth, failed jobs, counts by research status, authentication failures, rate limits, and latency. Logs should contain record identifiers, hosts, status classes, and failure codes, but never tokens, full response bodies, or spreadsheet contents.

Common failures

  • Every row reports authentication: confirm plan activation and the deployed token. If it was regenerated, replace the revoked value and rebuild the configuration cache.
  • Rows remain pending: verify that a queue worker is running against the same queue connection and environment as the web application.
  • Only some rows are rate-limited: let queued backoff work; do not start more workers as an automatic response.
  • The CSV imports nothing: verify UTF-8 encoding and an exact website header.
  • A successful response becomes invalid_response: inspect a securely captured structural summary, not sensitive response values, and adjust only the boundary mapper after consulting the official documentation.

Final verification checklist

  • The service plan is active and the current service-scoped token exists only in environment-backed configuration.
  • The minimal request reaches the exact GET endpoint with token and website query parameters.
  • A CSV containing valid, duplicate, and malformed websites produces expected queued and skipped counts.
  • Workers persist company, contact, email, phone, and people without assuming undocumented nested shapes.
  • Authentication and validation failures stop immediately, while transient failures use bounded retries and backoff.
  • The authenticated review page shows completed, pending, and failed rows without making remote calls.
  • Tests pass with Http::fake(), and logs contain no credential or response payload.

The spreadsheet is no longer a brittle batch request. It has become a small, observable research pipeline: every website has a durable state, every remote response crosses a guarded boundary, and every uncertain result remains available for human review. That combination of automation and inspectability is what makes contact enrichment useful in production rather than merely impressive in a demo.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.