danjamesmills/companies-house

A Laravel package wrapper around the Companies House Public Data API

Maintainers

Package info

github.com/DanJamesMills/companies-house

Homepage

pkg:composer/danjamesmills/companies-house

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 3

1.0.0 2026-04-28 13:41 UTC

README

Latest Version on Packagist Tests Code Style Total Downloads License

A Laravel package for the Companies House API. Look up any UK company, its officers, filing history, charges, PSC data, and more — all with a clean fluent interface. Also supports the real-time Streaming API for keeping a local database in sync as changes happen.

// Look up a company
$company = CompaniesHouse::company('09717426')->profile();

// Search for companies
$results = CompaniesHouse::search()->companies('ACME Ltd');

// Stream real-time changes
CompaniesHouseStream::companies(function (array $event) {
    // called for every company update as it happens
});

Why use this package?

  • The raw Companies House API uses HTTP Basic Auth with non-obvious conventions — this handles all of that for you
  • Typed exceptions for every error case (401, 404, 429, 416) so you can handle them cleanly
  • Streaming API support built in, with the correct Guzzle configuration for long-lived connections
  • 99% test coverage, Laravel 10/11/12 compatible

API reference: The full response shapes for every endpoint are documented in the Companies House API spec. The Streaming API spec covers the real-time feed format.

Contents

How it works

The package authenticates every request using HTTP Basic Auth with your API key as the username and an empty password, exactly as the Companies House spec requires. All responses are returned as plain PHP arrays.

The package is organised into resources that mirror the API structure:

Resource Access via Description
Company profile + address CompaniesHouse::company($number) Core company data
Officers ->officers() Directors, secretaries, LLP members
Charges ->charges() Mortgages and charges
Filing history ->filingHistory() All filed documents
PSC ->personsWithSignificantControl() Beneficial ownership
Search CompaniesHouse::search() All search endpoints
Documents CompaniesHouse::documents() Download actual PDFs
Disqualified officers CompaniesHouse::disqualifiedOfficers() Disqualification records
Officer appointments CompaniesHouse::officer($id) All roles for one officer
Streaming API CompaniesHouseStream::companies() Real-time change feed

Installation

composer require danjamesmills/companies-house

Publish the config file:

php artisan vendor:publish --tag="companies-house-config"

Configuration

Add your Companies House API keys to .env:

# REST API key - for all on-demand endpoints
COMPANIES_HOUSE_API_KEY=your-api-key-here

# Streaming API key - for real-time change feeds (separate registration required)
COMPANIES_HOUSE_STREAM_API_KEY=your-stream-api-key-here

Get a REST API key by registering at the Companies House Developer Hub. Register it as an API Key application (not OAuth).

Get a streaming API key by registering a separate Streaming API application at Your Applications. REST and streaming keys are not interchangeable.

Security: Never commit your API keys to source control. Store them in .env only.

Usage

Company Profile

use DanJamesMills\CompaniesHouse\Facades\CompaniesHouse;

$company = CompaniesHouse::company('09717426')->profile();

// Key fields:
// $company['company_name']
// $company['company_number']
// $company['company_status']        (active, dissolved, liquidation, etc.)
// $company['type']                  (ltd, plc, llp, etc.)
// $company['date_of_creation']
// $company['registered_office_address']
// $company['etag']                  (changes when data changes, useful for caching)

$address = CompaniesHouse::company('09717426')->registeredOfficeAddress();

// Optional endpoints - will throw NotFoundException if no data exists for this company
$registers     = CompaniesHouse::company('09717426')->registers();
$insolvency    = CompaniesHouse::company('09717426')->insolvency();
$exemptions    = CompaniesHouse::company('09717426')->exemptions();
$establishments = CompaniesHouse::company('09717426')->ukEstablishments();

Note: registers, insolvency, exemptions, charges, and uk-establishments throw a NotFoundException when the company has no data for that resource. This is normal, so always catch it.

Officers

// List all officers (directors, secretaries, etc.)
$officers = CompaniesHouse::company('09717426')->officers()->list();

// Paginate
$officers = CompaniesHouse::company('09717426')->officers()->list(
    itemsPerPage: 25,
    startIndex: 0,
    orderBy: 'surname', // 'appointed_on', 'resigned_on', 'surname'
);

// Only active directors
$officers = CompaniesHouse::company('09717426')->officers()->list(registerType: 'directors');
// registerType options: 'directors', 'secretaries', 'llp-members'

// Get a specific appointment
$appointment = CompaniesHouse::company('09717426')->officers()->get('appointmentId');

Charges (Mortgages)

// All charges
$charges = CompaniesHouse::company('09717426')->charges()->list();

// Only outstanding charges
$charges = CompaniesHouse::company('09717426')->charges()->list(filter: 'outstanding');
// filter options: 'outstanding', 'part-satisfied', 'satisfied'

// Get a specific charge
$charge = CompaniesHouse::company('09717426')->charges()->get('chargeId');

Filing History

// All filings (most recent first)
$history = CompaniesHouse::company('09717426')->filingHistory()->list();

// Filter by category
$history = CompaniesHouse::company('09717426')->filingHistory()->list(category: 'accounts');
// categories: 'accounts', 'confirmation-statement', 'incorporation',
//             'officers', 'persons-with-significant-control', 'address', 'other'

// Paginate through all filings
$history = CompaniesHouse::company('09717426')->filingHistory()->list(
    itemsPerPage: 25,
    startIndex: 25,
);
// Total count is in $history['total_count']

// Get a specific filing
$filing = CompaniesHouse::company('09717426')->filingHistory()->get('transactionId');

Persons with Significant Control (PSC)

// List all PSCs
$pscs = CompaniesHouse::company('09717426')->personsWithSignificantControl()->list();

// Individual PSC (person)
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->individual('notificationId');

// Corporate entity PSC (a company owns shares)
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->corporateEntity('notificationId');

// Legal person PSC
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->legalPerson('notificationId');

// Beneficial owners
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->individualBeneficialOwner('notificationId');
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->corporateEntityBeneficialOwner('notificationId');
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->legalPersonBeneficialOwner('notificationId');

// Super secure PSCs (identity protected)
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->superSecure('superSecureId');
$psc = CompaniesHouse::company('09717426')->personsWithSignificantControl()->superSecureBeneficialOwner('superSecureId');

// PSC statements
$statements = CompaniesHouse::company('09717426')->personsWithSignificantControl()->listStatements();
$statement  = CompaniesHouse::company('09717426')->personsWithSignificantControl()->getStatement('statementId');

// Notifications for a specific PSC
$notifications = CompaniesHouse::company('09717426')->personsWithSignificantControl()->notifications('pscId');

Search

// Search for anything (companies, officers, disqualifications)
$results = CompaniesHouse::search()->all('ACME Ltd');

// Search companies only
$results = CompaniesHouse::search()->companies('ACME Ltd', itemsPerPage: 20);
$results = CompaniesHouse::search()->companies('ACME Ltd', restrictions: 'actively-trading');
// restrictions: 'actively-trading', 'liquidation', 'receivership', 'administration', etc.

// Search officers by name
$results = CompaniesHouse::search()->officers('John Smith');

// Search disqualified officers
$results = CompaniesHouse::search()->disqualifiedOfficers('John Smith');

// Advanced company search (multiple filters)
$results = CompaniesHouse::search()->advanced([
    'company_name_includes' => 'ACME',
    'company_status'        => ['active'],
    'company_type'          => ['ltd'],
    'sic_codes'             => ['62012'],
    'incorporated_from'     => '2010-01-01',
    'incorporated_to'       => '2020-12-31',
    'size'                  => 50,
]);

// Alphabetical search
$results = CompaniesHouse::search()->alphabetical('ACME');

// Dissolved companies
$results = CompaniesHouse::search()->dissolved('ACME', searchType: 'begins_with');
$results = CompaniesHouse::search()->dissolved('ACME',
    searchType:    'contains',
    dissolvedFrom: '2020-01-01',
    dissolvedTo:   '2023-12-31',
);

Disqualified Officers

// Natural person disqualifications
$disqualifications = CompaniesHouse::disqualifiedOfficers()->natural('officerId');

// Corporate body disqualifications
$disqualifications = CompaniesHouse::disqualifiedOfficers()->corporate('officerId');

Officer Appointments (all roles for one person)

// All companies a person is/was an officer of
$appointments = CompaniesHouse::officerAppointments('officerId');

// Paginate
$appointments = CompaniesHouse::officerAppointments('officerId', itemsPerPage: 25, startIndex: 0);

Downloading Filing Documents

Every filing history item has a links.document_metadata URL. Pass it directly to documents().

$history = CompaniesHouse::company('09717426')->filingHistory()->list(category: 'accounts');

foreach ($history['items'] as $item) {
    // Paper-filed documents may not have a download link
    if (! isset($item['links']['document_metadata'])) {
        continue;
    }

    $metadataUrl = $item['links']['document_metadata'];

    // Check metadata first - confirms available formats and number of pages
    $meta = CompaniesHouse::documents()->metadata($metadataUrl);

    // Download as PDF (returns raw binary string)
    $pdf = CompaniesHouse::documents()->pdf($metadataUrl);
    Storage::put("filings/{$item['transaction_id']}.pdf", $pdf);

    // Or XHTML (machine-readable structured format, where available)
    $xhtml = CompaniesHouse::documents()->xhtml($metadataUrl);
}

Note: paper_filed: true items may return a NotFoundException on download. Always check for links.document_metadata before attempting a download.

Streaming API

How it works

Instead of you calling Companies House and asking "what's changed?", the streaming API works the other way around: your server makes one long HTTP connection to stream.companieshouse.gov.uk and leaves it open. Companies House then pushes each change down that connection as it happens, line by line, indefinitely - like downloading an infinitely long file.

Your code processes each line (one JSON event per line) as it arrives. The connection stays open until the server closes it (maintenance, congestion) or your process dies. You are not polling anything — you are just reading from an open socket.

Your server  ──── GET /companies ────►  stream.companieshouse.gov.uk
             ◄─── event (line) ─────
             ◄─── event (line) ─────
             ◄─── (blank heartbeat) ─
             ◄─── event (line) ─────
             ◄─── ...forever... ────

The timepoint

Every event includes a timepoint, a large integer that acts as a position marker in Companies House's queue. You must save this after every event you successfully process. It is the only way to resume without missing changes if your connection drops.

timepoint 187124872480  ← processed ✓ (saved)
timepoint 187124872481  ← processed ✓ (saved)
timepoint 187124872482  ← connection dropped here
                              ↓
          reconnect with timepoint 187124872482
          Companies House replays from that position

If you don't pass a timepoint, the stream starts from "right now" and you will miss any changes that happened while you were disconnected.

Prerequisites:

  • A streaming API key registered separately at the Developer Hub (COMPANIES_HOUSE_STREAM_API_KEY) - REST keys do not work here
  • A PHP process that can run indefinitely (Artisan command under Supervisor, not a web request)
  • Maximum 2 concurrent connections per account

Event envelope

Each line pushed down the connection is a JSON object with this structure:

[
    'event' => [
        'timepoint'      => 187124872486, // save this after processing
        'published_at'   => '2024-03-15T10:30:00Z',
        'type'           => 'changed',    // 'changed' or 'deleted'
        'fields_changed' => ['company_status', 'date_of_cessation'],
    ],
    'resource_id'   => '09717426',        // the company number, officer ID, etc.
    'resource_kind' => 'company-profile',
    'resource_uri'  => '/company/09717426',
    'data'          => [ /* full resource, same shape as the on-demand REST API */ ],
]

Basic usage

use DanJamesMills\CompaniesHouse\Facades\CompaniesHouseStream;

// This call blocks until the connection is closed by the server.
// Run it inside a long-lived process (Artisan command), not a web request.
CompaniesHouseStream::companies(function (array $event) {
    $companyNumber = $event['resource_id'];
    $data          = $event['data'];
    $timepoint     = $event['event']['timepoint'];

    // 1. Do something with the change
    Company::updateOrCreate(
        ['company_number' => $companyNumber],
        ['name' => $data['company_name'], 'status' => $data['company_status']]
    );

    // 2. Save the timepoint AFTER successful processing so you can resume here
    Cache::put('ch.stream.timepoint', $timepoint);
});

Available streams

// Company profile changes
CompaniesHouseStream::companies($callback, $timepoint);

// Filing history changes
CompaniesHouseStream::filings($callback, $timepoint);

// Insolvency case changes
CompaniesHouseStream::insolvencyCases($callback, $timepoint);

// Charge (mortgage) changes
CompaniesHouseStream::charges($callback, $timepoint);

// Officer appointment changes
CompaniesHouseStream::officers($callback, $timepoint);

// PSC changes
CompaniesHouseStream::personsWithSignificantControl($callback, $timepoint);

// Disqualified officer changes
CompaniesHouseStream::disqualifiedOfficers($callback, $timepoint);

// Company exemption changes
CompaniesHouseStream::companyExemptions($callback, $timepoint);

// PSC statement changes
CompaniesHouseStream::pscStatements($callback, $timepoint);

Connection limit: Companies House allows a maximum of 2 concurrent streaming connections per account. Do not open multiple streams in a single process. Each method call opens one connection and blocks indefinitely. Run each stream as a separate OS process managed by Supervisor (see below).

In practice, most applications only need companies and filings. The other streams (insolvencyCases, charges, officers, personsWithSignificantControl, disqualifiedOfficers, companyExemptions, pscStatements) are very low-volume and are often better served by polling the REST API on a schedule rather than holding a permanent streaming connection open.

Resuming after a disconnect

Load the last saved timepoint and pass it when reconnecting. Companies House will replay every event from that position forward so nothing is missed:

$lastTimepoint = Cache::get('ch.stream.timepoint'); // null on first run

CompaniesHouseStream::companies(
    callback:  fn (array $event) => processEvent($event),
    timepoint: $lastTimepoint, // null = start from now; integer = resume from here
);

If the saved timepoint is too old (Companies House only keeps a finite backlog), a StreamRangeException is thrown. At that point you will need to re-import a full data snapshot. Companies House publishes these separately, and each snapshot includes the timepoint it was taken at so you can resume without missing anything.

Running as an Artisan command

The stream blocks for as long as the connection is alive. The recommended pattern is a dedicated Artisan command kept running by Supervisor:

// app/Console/Commands/StreamCompaniesHouse.php

class StreamCompaniesHouse extends Command
{
    protected $signature   = 'companies-house:stream';
    protected $description = 'Process real-time Companies House change events';

    public function handle(): void
    {
        $timepoint = Cache::get('ch.stream.timepoint');

        $this->info('Connecting to Companies House stream' . ($timepoint ? " from timepoint {$timepoint}" : '') . '...');

        try {
            CompaniesHouseStream::companies(
                callback: function (array $event) {
                    // process the event...
                    Cache::put('ch.stream.timepoint', $event['event']['timepoint']);
                },
                timepoint: $timepoint,
            );
        } catch (StreamRangeException $e) {
            $this->error('Timepoint expired. Re-import a snapshot and update ch.stream.timepoint.');
        } catch (RateLimitException $e) {
            $this->warn('Rate limited. Waiting ' . ($e->getRetryAfter() ?? 60) . 's before reconnect.');
            sleep($e->getRetryAfter() ?? 60);
        }
    }
}

How Laravel keeps the connection alive

A normal Laravel web request is limited to a few seconds and then times out. The streaming connection can run for hours or days, so it cannot run inside a web request. It must run in a long-lived PHP process. The standard way to handle this in Laravel is an Artisan command managed by Supervisor.

Supervisor is a process manager that keeps your command running permanently. If the command exits (server restart, connection drop, exception), Supervisor automatically restarts it. Here is a minimal Supervisor config:

; /etc/supervisor/conf.d/companies-house-stream.conf

[program:companies-house-stream]
command=php /var/www/html/artisan companies-house:stream
directory=/var/www/html
autostart=true
autorestart=true
startretries=10
stdout_logfile=/var/log/supervisor/companies-house-stream.log
stderr_logfile=/var/log/supervisor/companies-house-stream.log

The flow is:

  1. Supervisor starts php artisan companies-house:stream
  2. The command connects to Companies House and starts processing events
  3. If the connection drops (server maintenance, network blip), the command exits
  4. Supervisor immediately restarts it
  5. The command loads the last saved timepoint and reconnects, with no events missed

Laravel Horizon / queue workers are not the right tool here. Queue workers process discrete jobs and then stop. The stream connection is continuous and cannot be expressed as a queued job.

Streaming vs Polling

Here is how to decide between the two approaches. Both work fine within the rate limits.

Polling the REST API (every N minutes)

  • Your Laravel scheduler calls CompaniesHouse::company($number)->profile() for each company you track
  • Simple to understand and deploy, just a scheduled job
  • Rate limit is 600 requests per 5-minute window (2/second)
  • Works fine for small datasets; 500 companies polled every 30 minutes is roughly 17 requests/minute, well within the limit
  • Does not scale to large datasets; 50,000 companies every 30 minutes is 1,667 requests/minute, which exceeds the limit
  • You will always be slightly out of date (up to 30 minutes behind)

Streaming API (long-running connection)

  • One persistent connection, Companies House pushes changes to you within seconds
  • Unlimited throughput — you receive every change regardless of how many companies there are
  • Requires a dedicated long-running process managed by Supervisor (as shown above)
  • More complex to deploy and monitor
  • Requires a separate streaming API key

Quick decision guide:

Polling Streaming
Dataset size Small (< ~5,000) Any size
Freshness needed Minutes/hours is fine Near real-time
Infrastructure Scheduler only Supervisor daemon
Setup complexity Low Medium

Rate Limiting

The API allows 600 requests per 5-minute window per API key. Exceeding this returns a 429 which the package converts to a RateLimitException.

Tips for staying within limits:

  • Cache responses — company profiles and officer lists rarely change minute-to-minute
  • Use the etag field to detect changes before fetching full data (see below)
  • Process bulk lookups via queued jobs with rate-awareness, not in a single loop
  • Contact Companies House if your application consistently needs more than 600/5min

ETags

Most API responses include an etag field in the JSON body. It is a hash that changes whenever the data changes. Store it alongside your cached data and compare it on subsequent fetches to avoid unnecessary updates.

use App\Models\Company;

// First fetch - store the etag
$profile = CompaniesHouse::company('09717426')->profile();

Company::updateOrCreate(
    ['company_number' => '09717426'],
    [
        'data'       => $profile,
        'etag'       => $profile['etag'],
        'fetched_at' => now(),
    ]
);

// Later - check if data has changed before updating your local copy
$stored = Company::find('09717426');
$fresh  = CompaniesHouse::company('09717426')->profile();

if ($fresh['etag'] !== $stored->etag) {
    $stored->update([
        'data'       => $fresh,
        'etag'       => $fresh['etag'],
        'fetched_at' => now(),
    ]);
}

For a batch of 500 companies, this means 500 requests to check for changes, with extra requests only for the ones that have actually changed, rather than fetching all 500 every time.

Caching with Laravel Cache

use Illuminate\Support\Facades\Cache;

// Cache company profile for 1 hour
$profile = Cache::remember("ch.company.09717426", 3600, function () {
    return CompaniesHouse::company('09717426')->profile();
});

// Cache officer list for 6 hours (changes infrequently)
$officers = Cache::remember("ch.officers.09717426", 21600, function () {
    return CompaniesHouse::company('09717426')->officers()->list();
});

// Bust the cache when an etag change is detected
Cache::forget("ch.company.09717426");

Storing to Database

For applications that need offline reporting or historical tracking, store responses in your database.

Example migration:

Schema::create('companies_house_companies', function (Blueprint $table) {
    $table->string('company_number', 8)->primary();
    $table->string('company_name');
    $table->string('company_status');
    $table->string('etag')->nullable();     // track changes
    $table->json('raw_data');               // full API response
    $table->timestamp('last_synced_at')->nullable();
    $table->timestamps();
});

Sync job example with rate limit handling:

class SyncCompanyData implements ShouldQueue
{
    public function __construct(private string $companyNumber) {}

    public function handle(): void
    {
        $existing = CompaniesHouseCompany::find($this->companyNumber);

        try {
            $profile = CompaniesHouse::company($this->companyNumber)->profile();
        } catch (NotFoundException $e) {
            // Company may have been dissolved/removed from the register
            return;
        } catch (RateLimitException $e) {
            // Re-queue after the rate limit window resets (default 5 minutes)
            $this->release($e->getRetryAfter() ?? 300);
            return;
        }

        // Skip update if nothing has changed
        if ($existing && $existing->etag === $profile['etag']) {
            return;
        }

        CompaniesHouseCompany::updateOrCreate(
            ['company_number' => $this->companyNumber],
            [
                'company_name'   => $profile['company_name'],
                'company_status' => $profile['company_status'],
                'etag'           => $profile['etag'],
                'raw_data'       => $profile,
                'last_synced_at' => now(),
            ]
        );
    }
}

Error Handling

All exceptions extend CompaniesHouseException, so you can catch that as a catch-all, or catch the specific types for fine-grained control.

Exception HTTP Status Cause
AuthenticationException 401 API key is invalid or missing
NotFoundException 404 Company/resource not found, or optional resource has no data
RateLimitException 429 Exceeded rate limit (600 req/5min REST; reconnect backoff for streams)
StreamRangeException 416 Streaming timepoint is too old, re-import a snapshot
CompaniesHouseException other Any other API error
use DanJamesMills\CompaniesHouse\Exceptions\AuthenticationException;
use DanJamesMills\CompaniesHouse\Exceptions\CompaniesHouseException;
use DanJamesMills\CompaniesHouse\Exceptions\NotFoundException;
use DanJamesMills\CompaniesHouse\Exceptions\RateLimitException;

try {
    $company = CompaniesHouse::company('09717426')->profile();
} catch (AuthenticationException $e) {
    // Invalid API key - check COMPANIES_HOUSE_API_KEY in .env
} catch (NotFoundException $e) {
    // Company doesn't exist, or optional resource (registers, insolvency, etc.) has no data
} catch (RateLimitException $e) {
    // 600 req / 5 min limit hit
    $retryAfter = $e->getRetryAfter(); // seconds until reset, if provided
} catch (CompaniesHouseException $e) {
    $e->getMessage();    // Error description
    $e->getStatusCode(); // HTTP status code
    $e->getBody();       // Raw response body as array
}

Note: Optional endpoints (registers, insolvency, exemptions, charges, uk-establishments) return a NotFoundException when a company simply has no data for that resource — this is normal behaviour, not an error.

Testing

The package uses Pest with Orchestra Testbench so tests run without a full Laravel application.

# From the package directory
cd packages/danjamesmills/companies-house

# Install dev dependencies
composer install

# Run all tests
composer test

# Run a specific suite
vendor/bin/pest --testsuite=Unit
vendor/bin/pest --testsuite=Feature

# Run with coverage (requires Xdebug or PCOV)
composer test-coverage

Checking code style

# Check only (exit 1 if anything needs fixing)
composer format:check

# Auto-fix
composer format

The CI pipeline runs both checks automatically on every push and pull request.

Contributing

Contributions are welcome. To avoid wasted effort, please open an issue first to discuss what you have in mind, whether that's a bug report, a new endpoint, a design question, or something else. Pull requests that haven't been discussed in an issue may be closed without review.

  1. Open an issue describing what you want to change and why
  2. Wait for feedback before writing code
  3. Fork the repository, create a branch, and submit a PR referencing the issue
  4. Ensure composer test and composer format:check both pass

License

MIT