flairuk / laravel-starlink
Starlink for Laravel: poll the device telemetry stream, decode its column-indexed rows, resolve alerts into raised and cleared events, and read the latest values per terminal and router.
Requires
- php: ^8.2
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- nesbot/carbon: ^3.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0|^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-05 11:41:14 UTC
README
Starlink for Laravel — Read device telemetry from the Starlink Telemetry API in Laravel 12 and 13: your terminals' throughput, latency, packet loss, obstruction and signal quality, your routers' health, and public IP allocations, with alerts turned into Laravel events.
- The stream, decoded. Starlink sends rows as bare arrays. Each row becomes a
Recordkeyed by column name, using the column names in the same response, so a reordered column never misreads a value. - Alerts as events. Starlink only says which alerts are active now.
AlertRaisedandAlertClearedfire when that changes, and the state survives a restart. - A consumer that keeps going.
php artisan starlink:telemetrypolls the stream at Starlink's recommended pace and waits out dropped connections, rate limits and outages. - The latest values on demand.
query()reads the current values per terminal and router without moving your place in the stream. - Tokens handled. OAuth client credentials, cached and replaced when they expire.
- Several accounts. One service account per Starlink account, chosen by name.
📦 Installation · 📡 The stream · 🚨 Alerts · 🔁 Running a consumer · 🔎 Latest values · ⚠️ Errors
📦 Installation
Requires PHP 8.2+ and Laravel 12 or 13. Laravel 13 needs PHP 8.3+.
composer require flairuk/laravel-starlink
Telemetry is available to Starlink enterprise accounts. On the Starlink account settings page, under API Service Accounts, add a service account with the Device telemetry, View permission, and copy its client ID and secret:
STARLINK_CLIENT_ID=… STARLINK_CLIENT_SECRET=…
Optional settings:
| Key | Default | |
|---|---|---|
STARLINK_BATCH_SIZE |
1000 |
Rows per stream request, up to 65000. |
STARLINK_MAX_LINGER_MS |
15000 |
How long one request may collect rows, up to 65000. |
STARLINK_TIMEOUT |
30 |
Seconds allowed on top of the linger. |
STARLINK_CACHE_STORE |
default store | Where tokens and alert state are kept. |
STARLINK_ACCOUNT |
default |
Which account the facade uses. |
To read more than one Starlink account, publish the config and add an entry per account:
php artisan vendor:publish --tag=starlink-config
'accounts' => [ 'default' => ['client_id' => env('STARLINK_CLIENT_ID'), 'client_secret' => env('STARLINK_CLIENT_SECRET')], 'fleet' => ['client_id' => env('STARLINK_FLEET_CLIENT_ID'), 'client_secret' => env('STARLINK_FLEET_CLIENT_SECRET')], ],
Starlink::account('fleet')->telemetry()->stream();
Important
Each service account has its own place in the stream, and every read moves it on. Give each environment (local, staging, production) its own service account, or they will take rows from each other.
📡 The stream
use FLAIRUK\Starlink\Facades\Starlink; $batch = Starlink::telemetry()->stream(); foreach ($batch->userTerminals() as $terminal) { $terminal->deviceId(); // 'ut12345678-a12b3456-123456c1' $terminal->timestamp(); // CarbonImmutable, UTC, to the microsecond $terminal->get('DownlinkThroughput'); // Mbps $terminal->get('PingLatencyMsAvg'); $terminal->get('PingDropRateAvg'); // 0.0 to 1.0 $terminal->get('ObstructionPercentTime'); $terminal->get('SignalQuality'); // 0 to 1 $terminal['CountryCode']; // array access works too $terminal->alerts(); // [64 => 'high_sky_obstruction'] } $batch->routers(); // WifiUptimeS, InternetPingLatencyMs, Clients, WanRxBytes, DishId, … $batch->ipAllocations(); // Ipv4, Ipv6Ue, Ipv6Cpe $batch->withAlerts(); // every row with an active alert
Each request returns up to batchSize rows collected over at most maxLingerMs, for every device on the account. You can't filter the stream by device. An empty batch is normal.
Things to know about the stream:
- Every read moves the stream on. Rows that have been returned are not sent again. Store them before doing anything that can fail.
- Rows are kept for 8 hours. A consumer that is down for longer loses the rows in between.
- Poll continuously. Terminals and routers report every 15 seconds. A consumer that reads slower than the account writes falls behind and keeps getting old rows.
- Every value can be null, and new columns appear over time. Read with
get('Column', $default). The full list of columns is in Starlink's guide. - An IP allocation row with no addresses means the terminal no longer holds public IPs:
$record->isTombstone().
🚨 Alerts
Each row lists the alert codes active on the device. They are named from the same response's metadata, which Starlink says is the only reliable mapping:
$terminal->alerts(); // [80 => 'thermal_shutdown', 64 => 'high_sky_obstruction'] $terminal->hasAlert(80); $terminal->hasAlert('thermal_shutdown');
Starlink only reports what is active now. The consumer compares each device's alerts with its previous row and dispatches:
| Event | When |
|---|---|
TelemetryReceived |
A batch with at least one row arrived. Has $account and $batch. |
AlertRaised |
An alert is active that was not in the device's previous row. Has $account, $record, $code, $name. |
AlertCleared |
An alert from the device's previous row is no longer active. |
use FLAIRUK\Starlink\Events\AlertRaised; Event::listen(function (AlertRaised $event) { if ($event->name === 'thermal_shutdown') { Notification::route('slack', config('services.slack.ops')) ->notify(new TerminalOverheating($event->deviceId())); } });
The alerts each device has active are kept in the cache, so a restarted consumer does not raise them all again. $consumer->reset() forgets them. A device that goes offline sends no rows, so its alerts stay raised until it reports again.
Starlink's alert reference lists the codes. Starlink currently documents a known issue: devices on software 2025.06.05 or later report software_update_reboot_pending when no reboot is pending.
🔁 Running a consumer
php artisan starlink:telemetry php artisan starlink:telemetry --account=fleet --batch-size=5000 --linger=15000
It polls until stopped, printing a line per batch and each alert raised or cleared. Your listeners run between batches, so keep them quick and send slow work to a queue. Dropped connections, 429s and 5xx answers are retried after a pause that grows to a minute. A refused key or a missing permission stops it with an error.
Keep one process running per account under Supervisor or similar:
[program:starlink-telemetry] command=php /var/www/app/artisan starlink:telemetry autorestart=true stopwaitsecs=90
SIGTERM lets the current batch finish before the process exits, so its rows reach your listeners. That needs the pcntl extension.
From code:
$consumer = Starlink::telemetry()->consumer(); $consumer->run( onBatch: fn (Batch $batch) => TelemetryRow::insert(/* … */), maxBatches: null, // run until stopped until: fn () => Cache::get('stop-starlink'), ); $consumer->poll(); // or one batch at a time
🔎 Latest values
When you only need each device's current state, use query(). It returns Starlink's typed values and does not touch the stream, so it is safe to call from a web request:
$snapshot = Starlink::telemetry()->query()->get(); // every terminal and router $snapshot = Starlink::telemetry()->query() ->userTerminals('ut12345678-a12b3456-123456c1') ->routers() ->get(); $snapshot->userTerminal('ut12345678-a12b3456-123456c1'); // ['downlinkThroughputMbps' => 120.5, 'popPingLatencyMsAvg' => 31, 'signalQuality' => 0.92, …] $snapshot->router('Router-012300205003000000000000'); $snapshot->find($id); // either kind $snapshot->terminals(); // Collection keyed by ID
⚠️ Errors
| Exception | When |
|---|---|
AuthenticationException |
The client ID or secret was refused, or a fresh token was refused too. |
PermissionException |
403: the service account lacks Device telemetry, View. |
RateLimitException |
429: more than 250 requests a minute for the account, or 1000 tokens in 15 minutes from one IP address. |
ConnectionException |
Starlink could not be reached. |
ConfigurationException |
The account is not configured, or has no client ID or secret. |
StarlinkException |
The base class, and the type for anything else. $e->status is the HTTP status and $e->errors Starlink's error list. |
An expired token (401) is replaced and the request sent once more before anything is thrown.
🧪 Testing
composer test
composer format
The tests use Http::fake() with the example responses from Starlink's documentation and never call the real API. Your own tests can do the same: fake */auth/connect/token and */telemetry/stream.
License
MIT. See LICENSE.md.