themsaid/forge-sdk
A PHP SDK for the Laravel Forge API that lets you manage servers, sites, deployments, databases, and more programmatically from Laravel or PHP applications with a clean, expressive interface.
The Forge SDK is a Saloon-powered HTTP client for the Laravel Forge API, so day-to-day it behaves like a typed, fluent API wrapper — not an Eloquent-style ORM.
composer require laravel/forge-sdk
php artisan vendor:publish --tag=forge-config # config/forge.php
FORGE_API_TOKEN=your-org-scoped-token
use Laravel\Forge\Facades\Forge;
// First use case: "what's on my account, and redeploy it"
foreach (Forge::servers()->lazy() as $server) {
foreach (Forge::server($server->id)->sites()->lazy() as $site) {
// $site->name, $site->id, ...
}
}
Where to look first:
config/forge.php — token, base URI, and any default org/team scoping.src/Resources/ — the fluent entry points (servers(), server($id)->sites(), databases(), deployments(), sshKeys(), recipes(), organizations(), …). This directory is the real API surface; the README only samples it.src/Requests/ — one Saloon request class per endpoint. When you need to know the exact payload keys or whether an endpoint is paginated, read the request rather than guessing.src/Responses/ / DTOs — the objects you actually consume. Treat these as read-mostly value objects.The facade resolves a shared client from the container. For a single token, that's all you need. For multi-tenant apps, build a client per token instead (Forge::make($token) or a bound singleton) — see below.
Fan-out over servers with queued jobs. The SDK makes it easy to write N+1 loops that will happily blow your rate limit. Convert iteration into a two-stage job pattern: one job enumerates servers and dispatches one child job per server, each of which does its own scoped reads/deploy.
class DeployAllSites implements ShouldQueue
{
public function handle(): void
{
foreach (Forge::servers()->lazy(perPage: 30) as $server) {
DeployServerSites::dispatch($server->id);
}
}
}
Use lazy() for anything unbounded. It returns a LazyCollection backed by the SDK's paginator, so memory stays flat across pages. This is exactly the path that was broken in 4.1.0 — see Gotchas.
Cache reads, not writes. Forge's API is rate-limited and the data changes slowly. Servers, regions, providers, and SSH keys are textbook Cache::remember candidates with a short TTL; deployments and provisioning status should never be cached.
$regions = Cache::remember('forge.regions', now()->addHour(), fn () => Forge::regions()->all());
Deploy-then-poll. Deployment and provisioning endpoints return immediately; the resource is not ready. Model this as a job with backoff that polls the deployment log or server status until terminal, with a bail-out after N attempts. Never assume createServer() produced a usable server.
Multi-tenant: one client per token, no facade. The facade's singleton token is a footgun in a SaaS app. Bind a factory:
// In a service provider
$this->app->bind(ForgeClient::class, fn ($app) => Forge::make(
token: $app['request']->user()->forge_token,
));
Then inject ForgeClient and call $client->servers() — same fluent surface, isolated credentials, no Forge::setToken() mutation leaking between requests (and especially between queued jobs, where the request-scoped user doesn't exist — pass the token or tenant ID into the job payload).
Wrap in a thin service layer. Don't sprinkle Forge:: calls through controllers. A ForgeProvisioner / ForgeDeployer class gives you one place to add retries, logging, caching, and a fake for tests. It also insulates you from SDK signature churn between minor versions.
Testing with Saloon fakes, not mocks. The SDK ships as a Saloon client, so record/replay responses with Saloon's mock client (Response::fake(...) / MockClient) against your service layer. You get assertions on the outgoing request and realistic DTO hydration for free.
Middleware as the extension point. Retries, backoff, custom headers, and request logging belong in Saloon middleware registered on the client, not in try/catch around every call. Centralizing this makes rate-limit handling a one-line config change.
v4.1.1 fixes silent data loss in lazy(). CursorPaginator::lazy() was overwriting items across pages, meaning multi-page iteration could return duplicates and drop records. If you pinned 4.1.0 and iterate servers, sites, or deployments with lazy(), upgrade immediately — there is no exception and no warning, your job just processed the wrong set. On 4.1.0, work around it by paging manually:
$page = 1;
do {
$result = Forge::servers()->paginate(perPage: 30, page: $page);
foreach ($result->items() as $server) { /* ... */ }
} while ($result->hasMorePages() && $page++);
Cursor pagination isn't page pagination. Cursor-based collections can't jump to arbitrary offsets, and there's no snapshot isolation — if servers are created/deleted while you iterate, you may see shifts. For reconciliation jobs, prefer a full listing plus a diff against your local table over incremental cursor walks.
Retries are not idempotent. Saloon will cheerfully retry a POST on a 5xx or timeout. createServer, createSite, and deploy are not idempotent — a retry can provision a duplicate or double-deploy. Add retry middleware only to GETs, or guard writes with your own dedupe key (e.g. a unique site name check before create).
403 vs 404 for token scoping. Forge tokens are organization-scoped. A token from the wrong org typically yields a 403 on the collection and a 404 on a specific resource, which reads like "server deleted" in logs. Log the org alongside the resource ID, and assert the expected org at startup with a cheap organizations() call.
Provisioning lag is not an error. createServer() succeeding means "queued", not "ready". Poll with exponential backoff; surface a pending state in your UI rather than blocking a request.
Don't mock the SDK's own classes. The package's test suite moved off Mockery in 4.1.1 (PR #237), which is a good hint for your app: Mockery::mock(Forge::class) breaks whenever a resource method signature changes. Fake at the HTTP layer instead, so your tests survive minor releases.
Debugging. Dump the underlying Saloon Response (status, headers, raw body) rather than the hydrated DTO — DTOs hide 429s and HTML error pages that a proxy returned. Add request/response logging middleware in local dev; the SDK gives you no per-call verbosity flag.
Respect the page-size ceiling on paginate()/lazy(); oversized perPage values are clamped server-side and you'll silently get fewer items than requested, which makes pagination math look wrong.
Watch the changelog for paginator changes specifically. The two 4.1.x releases are both plumbing — no new resources, no deprecations, no breaking changes. The paginator is the part of this SDK most likely to change behaviour silently, so pin a minor version ("laravel/forge-sdk": "~4.1.1") and read patch notes before bumping.
How can I help you explore Laravel packages today?