laravel/forge-sdk
The official Laravel Forge SDK offers an expressive PHP interface for Forge's API v2, letting you manage servers, sites, and organizations programmatically with paginated results and simple token-based authentication.
The package is a thin, resource-oriented HTTP client for Forge's API v2 — not a Laravel service provider with facades out of the box. You construct it yourself:
composer require laravel/forge-sdk
$forge = new Laravel\Forge\Forge($apiToken);
First thing to do: find your organization slug. Every v2 endpoint takes it as the first argument, so this is the one value you'll hardcode or configure everywhere:
$organizations = $forge->organizations();
$organizationSlug = $organizations[0]->slug; // store this in config/.env
Where to look first:
Laravel\Forge\Resources\* classes if you need to know which public properties a return value actually has (they're plain DTOs with public properties, no accessors or docblocks worth relying on).UPGRADE-4.0.md if you're touching any v3-era code — v2 is a hard break, no compatibility shims.First real use case: an Artisan command that lists servers and sites so you can confirm auth, org slug, and payload shapes before automating anything:
foreach ($forge->servers($organizationSlug) as $server) {
$this->line("{$server->id} {$server->name}");
}
Because the SDK is constructor-injected rather than container-bound, the first architectural decision is how you register it — typically a small singleton in a service provider reading the token from config. Do that before writing feature code, otherwise you'll be new Forge(...) scattered across the app with no place to inject a fake.
Bind a configured instance, never construct ad hoc. A singleton keyed off config('services.forge.token') gives you one place to swap in a mock during tests and one place to set a global timeout. Wrap it in your own thin service if you want to pre-bind the org slug, so call sites don't repeat it on all forty methods:
class ForgeClient
{
public function __construct(private Forge $forge, private string $org) {}
public function servers() { return $this->forge->servers($this->org); }
}
Cache the org slug. organizations() is a network round trip; the slug never changes. Resolve it once into config at deploy/setup time rather than calling it on every request.
Consume collections lazily for anything unbounded. Collection methods return Laravel\Forge\CursorPaginator. The default foreach walk only sees the current page — a silent correctness bug if you assume otherwise. Use ->lazy() for full traversal (it fetches the next cursor on demand, so it's memory-safe) and ->toArray() when you genuinely want one page, e.g. for a dashboard or JSON response:
foreach ($forge->servers($org)->lazy() as $server) { /* all servers */ }
$page = $forge->servers($org)->toArray(); // current page only
Treat write methods as two categories: async-waiting and fire-and-forget. Methods like createSite() and createDatabase() poll Forge until the resource is ready (30s default, TimeoutException on overrun). Others — server actions, worker restarts, deployments — return immediately. The daily-use pattern is to chain setTimeout() for slow provisioning and pass the $wait = false flag when queueing bulk work:
$site = $forge->setTimeout(120)->createSite($org, $serverId, $data);
$site = $forge->createSite($org, $serverId, $data, false); // don't block
Model multi-step provisioning as a queued job with explicit re-fetch. A realistic "new site" workflow is: createSite → createDomain → createCertificate → updateSiteEnvironment → createDeployment → poll deploymentStatus. Put that in a job, and after each step re-fetch the resource rather than trusting the object you got back — resources are snapshots.
Poll with your own loop for things the SDK won't wait on. deploymentStatus(), commandOutput(), scheduledJobOutput(), and serverEventOutput() are read-only endpoints; the SDK won't block for you. A simple while with sleep() and a deadline is the idiomatic approach, and it keeps timeouts under your control instead of buried in the SDK.
Use it for drift detection and auditing, not just writes. The read surface (env vars via siteEnvironment(), siteNginx(), sitePhp(), deploymentScript(), phpVersions(), log endpoints) is the highest-leverage part for day-to-day ops: scheduled commands that diff expected vs. actual config across many servers, alert on expiring activeDomainCertificate(), or snapshot deploymentScript() before changes.
Idempotency is your responsibility. The SDK throws on API errors but won't dedupe. Wrap create operations in existence checks (servers()->lazy() filtered by name, heartbeats(), backgroundProcesses()) so re-running a provisioning command doesn't duplicate resources.
Compose with Laravel primitives rather than inside the SDK. Scheduler for audit jobs, queues for provisioning, notifications on TimeoutException, and Http::fake()-style mocking at the client boundary for tests. The SDK has no events or hooks, so observability belongs in your wrapper.
The org slug is everywhere and forgetting it is the #1 migration error. v3 code copied from blog posts and Stack Overflow will not work — every signature changed in 4.0. Treat any pre-4.0 snippet as suspect.
foreach on a paginator is not "all results." This is the sharpest edge in the package: foreach ($forge->servers($org) as $s) iterates the current page only and gives no indication more exist. Default to ->lazy() unless you deliberately want the first page. Also note json_encode($paginator) works and yields the page snapshot — handy in JSON APIs, misleading if consumers assume it's the full set.
setTimeout() fluency looks immutable but check before assuming. The README uses $forge->setTimeout(120)->createSite(...), which reads as a fluent clone. If you rely on that being non-mutating and reuse one instance across a request, verify in source — a mutable timeout leaking between calls is a nasty intermittent bug.
Resources are value objects, not live handles. $server->name won't reflect a rename performed elsewhere. Re-fetch after any mutation, and don't serialize resources into queues hoping they'll refresh.
Timeout defaults bite on slow providers. Server creation and database provisioning routinely exceed 30s. Either raise the timeout per call or pass wait = false and verify asynchronously — otherwise TimeoutException looks like a failure when the operation actually succeeded server-side.
Some methods read like deletes but aren't destructive to data. deleteSiteNginxAccessLog() etc. clear log files under the site, not the site itself. Similarly, syncDatabases() is a refresh, not a teardown. Read carefully before wiring into a "clean up" command.
Naming is inconsistent across the surface. You'll find sites(), organizationSites(), and serverSites() for what feels like three variants of the same list; the same for site() vs organizationSite(). There's also daemons → backgroundProcesses terminology drift from v1. Keep the README open; intellisense on a Forge class with this many methods isn't a substitute for knowing which scope you need.
No HTTP-layer configurability is documented. There's no obvious place to inject retry/backoff, custom middleware, or a Guzzle client for rate-limit handling. Build retry logic around your calls, and be conservative with loop-and-poll designs (sleep() between status checks) rather than hammering the API.
Testing: since you construct new Forge($token), you can't swap it via the container unless you bind it yourself. Subclass or wrap it behind your own interface early — retrofitting testability onto a codebase of direct new Forge() calls is painful. Given the package has zero dependents on record, expect thin community examples; you'll be reading the README and resource classes, not blog posts.
Extension points are minimal by design. No events, no middleware, no custom resources. If you need behaviour beyond the published methods, wrap the client — don't try to subclass Forge and expect stable internals across minor releases.
How can I help you explore Laravel packages today?