jamesforsyth/laravel-uk-kyb-edge
A Laravel package for UK KYB (Know Your Business) checks, offering a clean API for company verification and edge-case handling. Configure services, run checks, and integrate compliance workflows quickly.
Install and configure
composer require <vendor>/uk-business
php artisan vendor:publish --tag=uk-business-config
Set the credentials in .env (names follow the published config — check config/uk-business.php after publishing):
UK_BUSINESS_API_KEY=
UK_BUSINESS_BASE_URI=
Where to look first
config/uk-business.php — base URI, key, timeout, retry settings.UKBusiness facade accessor and UKBusinessClient constructor — the client is a thin Guzzle wrapper, so every method maps to one endpoint.financials() and document().First use case — pull a structured balance sheet for a filed document:
use UKBusiness;
$financials = UKBusiness::financials($documentId);
// ['balance_sheet' => [...], 'period_end' => '...', ...] depending on the schema
Then stream the source PDF to confirm the numbers came from the right filing:
return UKBusiness::document($documentId)
->streamDownload("{$documentId}.pdf");
Start with financials() in a tinker session or an artisan command before wiring it into HTTP — see the timeout notes below.
Facade for one-offs, injected client for services. The facade resolves the singleton client; in longer-lived classes inject UKBusinessClient so you can swap it in tests without touching the container.
Treat financials() as a slow, billable job. Vision extraction is not a lookup — it parses a PDF. Push it to a queue with a generous timeout and an explicit, persisted result:
final class ExtractFinancials implements ShouldQueue
{
public int $timeout = 180;
public int $tries = 3;
public array $backoff = [30, 120];
public function __construct(public string $documentId) {}
public function handle(UKBusinessClient $client): void
{
$data = $client->financials($this->documentId);
CompanyDocument::where('document_id', $this->documentId)
->update([
'financials' => $data,
'extracted_at' => now(),
]);
}
public function retryUntil(): DateTimeInterface
{
return now()->addHours(2);
}
}
Cast the column to array/json and version the payload — extraction output can change shape between releases, so store the package version alongside it:
'financials' => $data,
'financials_schema' => 'v1.1.0',
Proxy the PDF, don't hotlink it. document() is described as streaming with CORS headers enabled, which is convenient for a browser but tempting to abuse. In a Laravel app you generally want your own signed, authenticated route in front of it:
Route::get('/documents/{document}', function (CompanyDocument $document) {
return UKBusiness::document($document->document_id)
->streamDownload("{$document->document_id}.pdf");
})->middleware(['auth', 'can:view,document']);
If you're genuinely exposing the upstream URL to a browser, understand that the CORS headers are applied by the registry, not by your app — your own config/cors.php governs any route you serve. Don't let the browser call the upstream directly with your API key.
Persist PDFs to disk, then re-run extraction from the stored copy. Download once, extract many. This makes re-extraction after a package upgrade cheap and keeps a local audit trail:
Storage::disk('documents')->writeStream(
"{$documentId}.pdf",
UKBusiness::document($documentId)->toPsrResponse()->getBody()
);
Cache extraction results on documentId. The same filing yields the same balance sheet; a cache-aside wrapper with a long TTL removes almost all repeat cost:
$financials = Cache::rememberForever(
"ukb:financials:{$documentId}",
fn () => UKBusiness::financials($documentId)
);
Fake in tests. Because the client is Guzzle-based, Http::fake() works if the package routes through Laravel's HTTP client; if not, bind a stub UKBusinessClient in the container and assert the job dispatched financials() once with the expected ID. Never hit the live registry in CI.
Chain the two methods for a "fetch and verify" flow: extract financials, store them, then queue a second job that downloads the PDF for archival. Keeping them separate means a PDF storage failure doesn't lose an expensive extraction.
Timeouts are the number-one failure mode. Guzzle's default timeout is far too short for vision extraction on a 60-page filing. Set an explicit per-call timeout (or a separate extraction_timeout in config) and make sure it's lower than your queue worker's --timeout, or the worker gets killed mid-request and you're billed twice.
Don't blindly retry extraction. A 500 or timeout may mean the extraction succeeded server-side but the response was lost. Key your job on document_id and make the write idempotent (upsert, not insert), otherwise retries produce duplicates.
Streaming is not the same as buffering. document() returns a streamed response; calling ->body() or ->json() on it loads the whole PDF into memory. Use streamDownload(), ->toPsrResponse()->getBody() into writeStream, or Storage::put with a stream. Large filings will blow past memory_limit otherwise.
CORS headers on the upstream response do not make your proxy CORS-correct. If your own route is consumed cross-origin, add it to your paths in config/cors.php. Also confirm whether the headers are on the success path only — error responses from a proxy frequently lose them and show up as opaque CORS failures in the browser console.
Validate the document ID before spending money. Registry IDs are not the same as company numbers. Reject malformed input at the FormRequest layer; a bad ID may still return 200 with an empty or partial extraction.
Scanned or image-only PDFs can return partial structures. Vision extraction is probabilistic: fields may be missing rather than erroring. Validate the response shape before persisting and mark documents as needs_review when key sections are absent instead of failing the job.
Handle 429s with backoff, not immediate retry. Registry endpoints rate-limit per key. Laravel's $backoff on the job plus a shared rate limiter (RateLimiter::for('uk-business', ...)) is the simplest defence if you fan out extractions.
The v1.1.0 client changes are the upgrade risk. The release notes say the Guzzle methods were updated — that's the layer everything else sits on. Even though it's a minor version, re-run your suite and check the return types of any method you were calling directly on UKBusinessClient rather than through the facade. If the client previously returned a raw Response, confirm that's unchanged for existing methods.
Debug by logging the raw body first. When extraction returns something unexpected, dump the unparsed response before any transformation — most "wrong numbers" reports are actually a mismatched document ID or a partially-scanned PDF, not a parsing bug.
Extension point: financials() returns a plain array, so wrap it in your own DTO/action class (FetchCompanyFinancials) rather than spreading the facade call across controllers. That's the seam where you'll add caching, versioning, and schema validation later.
How can I help you explore Laravel packages today?