mcpuishor / linode-laravel
A Laravel package for Linode integration
Requires
- php: ^8.4
- illuminate/support: ^13.0
Requires (Dev)
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
This package is auto-updated.
Last update: 2026-08-04 00:18:37 UTC
README
A Laravel package for the Linode API v4. Covers Compute Instances, Managed Databases (MySQL and PostgreSQL) and Regions.
Upgrading from 0.x? Read UPGRADE.md — 1.0 contains breaking changes, including two bug fixes that change behaviour you may have relied on.
Requirements
| Package | Version |
|---|---|
| PHP | 8.4+ |
| Laravel | 13.0+ |
Installation
composer require mcpuishor/linode-laravel
Publish the configuration:
php artisan vendor:publish --tag="linode-config"
Add your API key to .env:
LINODE_API_KEY=your-api-key LINODE_PAGE_SIZE=100 # optional, Linode's maximum is 500
Getting a client
use Mcpuishor\LinodeLaravel\LinodeClient; $linode = LinodeClient::make(); // static constructor $linode = app(LinodeClient::class); // from the container LinodeClient::instances()->all(); // or skip the client entirely
Listing and pagination
Every list endpoint is paginated. all() walks all pages lazily, holding one
page in memory at a time:
foreach ($linode->instances()->all() as $instance) { echo $instance->label; }
It returns a LazyCollection, so the usual pipeline works and nothing is
fetched until you iterate:
$linode->instances()->all() ->filter(fn ($i) => $i->status === 'running') ->take(10);
A
LazyCollectionre-runs its source each time it is iterated. Call->collect()once if you need to traverse the result repeatedly.
When you want a single page and its metadata:
$page = $linode->instances()->page(2, pageSize: 50); $page->data; // Collection<ValueObject> $page->page; // 2 $page->pages; // 7 $page->results; // 340 $page->hasMorePages(); // true
Compute Instances
$instances = $linode->instances(); $instances->all(); // LazyCollection, every page $instances->page(2); // one page + metadata $instances->get(123); // fetch one, as a ValueObject $instances->create([...]); $instances->update(123, [...]); $instances->delete(123); $instances->types(); // available plans $instances->type('g6-standard-1'); $instances->kernels(); $instances->kernel('linode/latest-64bit');
Working with one instance
find() binds an id without calling the API, and everything for that instance
hangs off the handle:
$server = $linode->instances()->find(123); $server->get(); // fetch it $server->update(['label' => 'web-1']); $server->delete();
Power
$server->boot(); $server->boot(configId: 456); $server->reboot(); $server->shutdown();
Lifecycle
$server->resize('g6-standard-4'); $server->resize('g6-standard-4', ['migration_type' => 'warm']); $server->rebuild('linode/ubuntu24.04', 'a-strong-root-password'); $server->rescue(['sda' => ['disk_id' => 456]]); $server->clone(['region' => 'eu-west']); $server->migrate(['region' => 'eu-west']); $server->mutate(); // upgrade to newer hardware $server->resetPassword('a-strong-root-password');
Backups
$server->backups()->all(); // automatic + snapshot + in-progress $server->backups()->enable(); $server->backups()->cancel(); $server->backups()->snapshot('pre-deploy'); $server->backups()->find(789); $server->backups()->restore(789); // back onto itself $server->backups()->restore(789, toLinodeId: 456, overwrite: true);
Disks
$server->disks()->all(); $server->disks()->create(['size' => 81920, 'filesystem' => 'ext4']); $disk = $server->disks()->find(456); $disk->get(); $disk->update(['label' => 'root']); $disk->resize(81920); // size in MB $disk->resetPassword('a-strong-root-password'); $disk->clone(['linode_id' => 789]); $disk->delete();
Configuration profiles
$server->configs()->all(); $server->configs()->create([...]); $config = $server->configs()->find(1); $config->get(); $config->update(['label' => 'boot']); $config->delete();
Networking
$server->ips(); // full networking picture $server->ip('192.0.2.1'); $server->allocateIp(['type' => 'ipv4', 'public' => true]); $server->updateIp('192.0.2.1', ['rdns' => 'web-1.example.com']); $server->deleteIp('192.0.2.1'); $server->firewalls(); $server->nodeBalancers(); $server->volumes();
Metrics
$server->stats(); // last 24 hours $server->stats(2026, 3); // an archived month $server->transfer(); // this month $server->transfer(2026, 3);
Managed Databases
Without an engine you get the account-wide view, which lists clusters of every engine:
$linode->databases()->all(); $linode->databases()->types(); $linode->databases()->type('g6-standard-1'); $linode->databases()->engines(); $linode->databases()->engine('mysql/8.0.26');
Selecting an engine returns a new instance scoped to it — the original is left alone:
$mysql = $linode->databases()->mysql(); $postgres = $linode->databases()->postgresql(); $mysql->all(); $mysql->get(123); $mysql->create([ 'label' => 'orders', 'region' => 'us-east', 'type' => 'g6-standard-1', 'engine' => 'mysql/8', 'cluster_size' => 3, ]); $mysql->update(123, ['label' => 'orders-primary']); $mysql->delete(123); $mysql->config(); // advanced configuration parameters
Operations on one cluster use find(), the same as instances:
$db = $linode->databases()->mysql()->find(123); $db->get(); $db->update(['allow_list' => ['192.0.2.1/32']]); $db->suspend(); $db->resume(); $db->patch(); // apply maintenance patches $db->credentials(); $db->resetCredentials(); $db->ssl(); // CA certificate for the selected engine $db->delete();
Operations requiring an engine throw EngineNotSelectedException when one has
not been chosen.
PostgreSQL connection pools (PostgreSQL only; calling this on MySQL throws):
$pools = $linode->databases()->postgresql()->find(123)->connectionPools(); $pools->all(); $pools->get('reporting'); $pools->create(['name' => 'reporting', 'mode' => 'transaction', 'size' => 25]); $pools->update('reporting', ['size' => 50]); $pools->delete('reporting');
Regions
$linode->regions()->all(); $linode->regions()->get('us-east'); $linode->regions()->availability(); // every region $linode->regions()->availability('us-east'); // one region
Working with responses
API responses come back as ValueObject, an immutable view over the decoded
payload. Attributes are read as properties, and nested objects stay navigable:
$instance = $linode->instances()->get(123); $instance->label; // "web-1" $instance->specs->vcpus; // nested objects $instance->tags; // lists stay plain arrays $instance->toArray(); $instance->toJson(); json_encode($instance);
Reading an attribute that is not in the response throws
UnknownAttributeException, so typos fail loudly rather than reading as null:
$instance->lable; // Unknown attribute [lable] ... Did you mean [label]?
For genuinely optional attributes:
$instance->has('image'); // true even when the value is null $instance->get('image', 'none'); // read with a default $instance->image ?? 'none'; // isset()/?? work and never throw $instance->lenient()->image; // null for anything missing
ValueObject is intentionally not a generated DTO. Linode adds response fields
continuously, and passing them straight through means you can read a new field
the day it ships rather than waiting for a release of this package.
Storing responses on a model
AsValueObject casts a JSON column to and from a ValueObject:
use Mcpuishor\LinodeLaravel\Casts\AsValueObject; class Server extends Model { protected $casts = [ 'linode_payload' => AsValueObject::class, ]; }
Error handling
Everything the package throws extends LinodeException:
LinodeException (abstract)
├── LinodeApiException the API returned an error response
├── EngineNotSelectedException a database engine was required but not chosen
├── ResourceNotFoundException a resource was expected but came back empty
├── UnexpectedResponseException a success response the package cannot use
└── UnknownAttributeException an attribute was read that does not exist
use Mcpuishor\LinodeLaravel\Exceptions\LinodeApiException; try { $linode->instances()->get(123); } catch (LinodeApiException $e) { $e->getResponse(); // the underlying Illuminate HTTP response $e->getErrorData(); // the API's `errors` array $e->getCode(); // the HTTP status code }
Direct API access
For endpoints the package does not wrap yet, use the transport directly. It handles the base URL, API version, authentication and error translation:
use Mcpuishor\LinodeLaravel\Transport; $transport = app(Transport::class); $transport->get('account/events', ['page' => 1]); $transport->post('account/tags', ['label' => 'production']); $transport->put('account/settings', [...]); $transport->delete('account/tags/production');
Testing
composer test
composer test:unit
composer test:feature
composer test:coverage
The suite is fully offline: tests/TestCase.php calls
Http::preventStrayRequests(), so any request that is not explicitly faked
fails the test rather than reaching the network.
License
MIT. See LICENSE.md.