mcpuishor/linode-laravel

A Laravel package for Linode integration

Maintainers

Package info

github.com/mcpuishor/linode-laravel

pkg:composer/mcpuishor/linode-laravel

Transparency log

Statistics

Installs: 40

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-08-04 00:18 UTC

README

A Laravel package for the Linode API v4. Covers Compute Instances, Managed Databases (MySQL and PostgreSQL) and Regions.

Tests

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 LazyCollection re-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.