A fluent PHP SDK for the Stable Diffusion WebUI API.
Built on PSR interfaces only — no HTTP client is bundled or required. If you do not inject one, it is discovered from whatever your project already has.
use Minhyung\SdWebUi\SdWebUi;
$sd = SdWebUi::create('http://127.0.0.1:7860');
$sd->txt2img()
->prompt('a lighthouse in a storm, dramatic lighting')
->negativePrompt('blurry, lowres')
->size(768, 512)
->steps(30)
->send()
->first()
->saveTo('lighthouse.png');- PHP 8.2+, with
ext-jsonandext-mbstring - A WebUI started with
--api
This package deliberately bundles no HTTP client, so you must supply one. Install it alongside:
composer require minhyung/sdwebui symfony/http-client nyholm/psr7or, with Guzzle:
composer require minhyung/sdwebui guzzlehttp/guzzle
composer require minhyung/sdwebuion its own is not enough. It will install cleanly —php-http/discoverysatisfies the PSR implementation requirements on paper — but the first call then fails with aConfigurationException, because there is no actual client to discover.
Alternative: let php-http/discovery install one for you
php-http/discovery ships a Composer plugin that installs a suitable client automatically, but plugins must be allowed explicitly:
composer config allow-plugins.php-http/discovery true
composer require minhyung/sdwebuiThat pulls in nyholm/psr7 and symfony/http-client for you. Installing a client yourself is still the more predictable route — and you need your own client instance anyway to set a timeout, which the next section explains is not optional here.
Already set up? A bare composer require minhyung/sdwebui is fine if the project has both a PSR-18 client and a PSR-7/17 implementation. Those are two separate things, and some packages only bring one:
| Installed | PSR-18 client | PSR-17 factories | Enough on its own |
|---|---|---|---|
guzzlehttp/guzzle |
yes | yes (guzzlehttp/psr7) |
yes |
symfony/http-client |
only with a PSR-7 impl | no | no — add nyholm/psr7 |
nyholm/psr7 |
no | yes | no — add a client |
PSR-18 has no concept of a timeout, so this SDK cannot set one. Generation runs synchronously on the server behind a lock, and a large batch or a slow model takes minutes. With a default client timeout of 30 seconds, your requests will fail long before the image is ready.
Inject a client configured for it:
use GuzzleHttp\Client;
$sd = SdWebUi::create('http://127.0.0.1:7860')
->httpClient(new Client(['timeout' => 600]));use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\HttpClient;
$sd = SdWebUi::create('http://127.0.0.1:7860')
->httpClient(new Psr18Client(HttpClient::create([
'timeout' => 600, // inactivity timeout
'max_duration' => 900, // total ceiling
])));For a server started with --api-auth user:pass:
$sd = SdWebUi::create('http://127.0.0.1:7860')->basicAuth('user', 'pass');Two things worth knowing:
- The server parses
--api-authby splitting on,then:, so a password containing either character cannot be configured server-side at all. --gradio-authdoes not protect/sdapi/v1/*, and a few extension-registered routes (the LoRA listing among them) bypass--api-authentirely. Do not treat the API as protected just because one of those flags is set.
use Minhyung\SdWebUi\Enum\Sampler;
use Minhyung\SdWebUi\Enum\Scheduler;
$result = $sd->txt2img()
->prompt('a cat sitting on a windowsill, golden hour')
->negativePrompt('blurry, lowres, watermark')
->size(768, 512)
->steps(30)
->cfgScale(7.0)
->sampler(Sampler::DpmPlusPlus2M) // or just 'DPM++ 2M'
->scheduler(Scheduler::Karras)
->seed(12345)
->batch(size: 2, iterations: 2) // four images
->send();
foreach ($result as $i => $image) {
$image->saveTo("out-{$i}.png");
}
$result->info()->seed(); // resolved seed
$result->info()->sdModelName(); // which checkpoint ran
$result->infotextFor(0); // the reproducible metadata blockEnums are a convenience, never a constraint — every setter that takes one also takes a plain string, so sampler names added by extensions keep working. Ask the server what it actually has with $sd->catalog()->samplerNames().
$sd->txt2img()
->prompt('a detailed landscape')
->size(512, 512)
->hiresFix(scale: 2.0, upscaler: 'R-ESRGAN 4x+', denoisingStrength: 0.4)
->send();use Minhyung\SdWebUi\Value\Image;
$sd->img2img()
->initImage(Image::fromFile('photo.png'))
->prompt('an oil painting')
->denoisingStrength(0.6)
->send();
$sd->img2img()
->initImage(Image::fromFile('photo.png'))
->mask(Image::fromFile('mask.png'))
->prompt('a red hat')
->onlyMasked()
->inpaintPadding(32)
->send();$sd->txt2img()
->prompt('a cat')
->checkpoint('sd_xl_base_1.0.safetensors [31e35c80fc]')
->clipSkip(2)
->send();Convenient, but slow in bulk: the server reloads the model and unloads it again on every request. Generating many images against one checkpoint is much faster if you set it once:
$sd->options()->checkpoint('sd_xl_base_1.0.safetensors [31e35c80fc]');Image handles the encoding so you do not have to:
Image::fromFile('in.png');
Image::fromBinary($bytes);
Image::fromBase64($base64OrDataUri);
Image::fromUrl('https://example.com/in.png'); // the server fetches it
$image->saveTo('out.png');
$image->toBinary();
$image->toBase64(); // bare base64 — what the API wants
$image->format(); // sniffed from magic bytes
$image->dimensions();Response images are not necessarily PNG — the server encodes them according to its samples_format setting. extension() and mimeType() reflect what actually arrived.
$sd->extras()
->image(Image::fromFile('small.png'))
->upscaler('R-ESRGAN 4x+')
->scaleBy(2.0)
->send()
->first()
->saveTo('large.png');
$sd->extrasBatch()
->add(Image::fromFile('a.png'), 'a.png')
->add(Image::fromFile('b.png'), 'b.png')
->upscaler('R-ESRGAN 4x+')
->scaleBy(2.0)
->send();$info = $sd->pngInfo(Image::fromFile('generated.png'));
$info->prompt();
$info->seed();
$info->infotext();
// Reproduce it
$sd->txt2img()->infotext($info->infotext())->send();The server restores only the fields the WebUI tags for the API, which is fewer than an
infotext contains. On txt2img that covers the prompts, size, CFG scale, batch size,
styles, denoising strength, the hires-fix block, steps, sampler, scheduler, seed and the
refiner. On img2img the tab's own fields are untagged, so denoising_strength,
inpainting_fill, inpaint_full_res, cfg_scale and the size are not restored there —
set them yourself. Anything you set explicitly always wins over the infotext.
If the server answers HTTP 500 ... cannot be used with isinstance(), it types one of
those fields as Any, which makes its own restore step raise before it applies anything.
Setting the field explicitly makes the server skip it. On Forge Neo the field is styles:
$sd->txt2img()->infotext($info->infotext())->styles([])->send();ControlNet and ADetailer have typed helpers:
use Minhyung\SdWebUi\Script\ADetailerUnit;
use Minhyung\SdWebUi\Script\ControlNetUnit;
$sd->txt2img()
->prompt('a person standing in a field')
->controlNet(fn (ControlNetUnit $u) => $u
->module('canny')
->model('control_v11p_sd15_canny [d14c016b]')
->image(Image::fromFile('pose.png'))
->weight(1.0)
->guidance(0.0, 0.8)
->pixelPerfect())
->adetailer(fn (ADetailerUnit $u) => $u
->model('face_yolov8n.pt')
->denoisingStrength(0.4))
->send();Call controlNet() more than once for multi-unit setups.
These helpers are best-effort. Argument layouts belong to the installed version of each extension, and the server silently discards arguments it does not expect — a wrong layout produces an image that quietly ignored your settings rather than an error. When something has no effect, check what the instance actually declares:
$sd->scripts()->find('controlnet'); // labels, defaults, ranges, choices
$sd->scripts()->argumentCount('adetailer');Any script can be driven directly:
$sd->txt2img()->prompt('a cat')->alwaysOn('MyExtension', [true, 0.5])->send();
$sd->txt2img()->prompt('a cat')->script('X/Y/Z plot', [/* ... */])->send();Generation blocks until it finishes, so progress has to be watched from somewhere else. Every request carries a task id for exactly this:
$request = $sd->txt2img()->prompt('a cat')->steps(50);
$taskId = $request->taskId(); // hand this to another process
$request->send();Then, from anywhere:
$progress = $sd->progress()->task($taskId);
$progress->percent(); // 42
$progress->eta(); // seconds
$progress->livePreview(); // ?Image
$progress->isFinished();
$progress->isUnknown(); // the server has no record of this idThis is per-task, so it stays correct when several clients share an instance. $sd->progress()->current() gives the server-wide snapshot instead.
isFinished() is the only positive signal that the work is done. isUnknown() means two opposite things depending on when it arrives: before the task has ever been seen it is "not submitted yet", because the id is published ahead of send(); after it has been seen it is "gone", since the server remembers only the last 16 finished tasks. A poll loop has to end on both, plus a deadline for the first sighting — examples/progress.php has the shape.
If you would rather stay in one process, onProgress() can do it — but it needs a client that both sends asynchronously and can be advanced in bounded slices, since a plain Promise::wait() would block until the image is done and defeat the purpose. Symfony\Component\HttpClient\HttplugClient qualifies, and needs all four of these:
composer require php-http/httplug symfony/http-client nyholm/psr7 guzzlehttp/promisesguzzlehttp/promises is easy to miss — Symfony's HttplugClient only reports it missing at the moment of the first async send.
use Symfony\Component\HttpClient\HttplugClient;
$sd = SdWebUi::create('http://127.0.0.1:7860')
->httpClient(new HttplugClient());
$sd->txt2img()
->prompt('a cat')
->steps(50)
->onProgress(function ($progress) {
echo $progress->percent() . "%\n";
}, intervalMs: 500)
->send();Clients that cannot be ticked are rejected with a clear error rather than hanging.
| Group | What it covers |
|---|---|
$sd->catalog() |
samplers, schedulers, checkpoints, VAEs, upscalers, LoRAs, styles, embeddings, hypernetworks, face restorers, extensions |
$sd->options() |
read and write the server's settings |
$sd->models() |
rescan model directories, unload and reload weights |
$sd->progress() |
current progress, per-task progress, interrupt, skip, memory |
$sd->scripts() |
installed scripts and their argument layouts |
$sd->server() |
ping, diagnostics, shutdown |
$sd->catalog()->sdModelTitles(); // ['v1-5-pruned.safetensors [abc123]', ...]
$sd->catalog()->upscalerNames();
$sd->options()->samplesFormat();
$sd->progress()->interrupt();$sd->server()->ping() answers false for any failure, including bad credentials — it is built for a health check, where the only question is whether to route traffic. For a "test connection" screen that has to tell the user what went wrong, call something real and let the typed exceptions through instead:
try {
$sd->catalog()->sdModels();
} catch (AuthenticationException $e) { // wrong --api-auth credentials
} catch (TransportException $e) { // host unreachable
}examples/inspect.php summarises a running instance: its sample format, and the total count plus first 20 entries of each of its checkpoints, samplers, schedulers, upscalers, LoRAs, and always-on scripts.
Writes through $sd->options() are persistent — they are saved to the server's config.json, not scoped to your session. Use overrideSetting() on a request for per-request changes. Note also that options flagged restrict_api server-side are ignored without an error, so read back anything that matters.
use Minhyung\SdWebUi\Exception\ApiException;
use Minhyung\SdWebUi\Exception\SdWebUiException;
use Minhyung\SdWebUi\Exception\TransportException;
use Minhyung\SdWebUi\Exception\ValidationException;
try {
$sd->txt2img()->prompt('a cat')->send();
} catch (ValidationException $e) {
foreach ($e->violations() as $violation) {
echo implode('.', $violation['loc']) . ': ' . $violation['message'] . "\n";
}
} catch (ApiException $e) {
$e->statusCode();
$e->detail();
} catch (TransportException $e) {
// Never reached the server: connection refused, or a timeout.
} catch (SdWebUiException $e) {
// Anything this SDK throws.
}| Exception | When |
|---|---|
BadRequestException |
400 — unknown sampler |
AuthenticationException |
401/403 |
NotFoundException |
404 — also a missing init_images, or an unknown interrogate model |
ValidationException |
422 — both FastAPI's itemised shape and the WebUI's own |
ServerException |
5xx — also an undecodable input image, or an unknown options key |
TransportException |
No response at all |
JsonException, ImageException, ConfigurationException |
Client-side |
Extension routes and anything newer than this SDK can go through the transport directly:
$sd->raw()->post('/my-extension/v1/thing', ['a' => 1]);
$sd->raw()->get('/my-extension/v1/status');Individual request fields have an escape hatch too:
$sd->txt2img()->prompt('a cat')->with('some_new_field', 42)->send();There is no middleware layer. Everything goes out through the PSR-18 client you inject, so decorate that — one implementation of sendRequest() covers retries, metrics, tracing, or an egress allowlist for every call this SDK makes:
final class GuardedClient implements \Psr\Http\Client\ClientInterface
{
public function __construct(private \Psr\Http\Client\ClientInterface $inner) {}
public function sendRequest(\Psr\Http\Message\RequestInterface $request): \Psr\Http\Message\ResponseInterface
{
// assert on $request->getUri()->getHost(), start a span, count a metric...
return $this->inner->sendRequest($request);
}
}
$sd = SdWebUi::create('http://127.0.0.1:7860')->httpClient(new GuardedClient($psr18Client));The base URL is validated as an absolute http/https URL with a host. Credentials embedded in it are rejected — use basicAuth(), which sends them as a header rather than as part of a URI that reaches every log line — and so are a query string and a fragment, since every request path is appended to the base URL and would land inside them. Validation does not resolve the host or restrict where it points; if untrusted input can reach the base URL, do that check yourself in a decorator like the one above.
The WebUI API has a number of sharp edges. These are absorbed so you do not have to think about them:
infois JSON inside JSON. Generation responses carryinfoas an encoded string; it is decoded transparently by$result->info().denoising_strengthhas no working default on txt2img. The server's schema generator lets the base class'snulloverwrite the documented0.75, so hires fix silently does nothing when it is unset. An explicit value is always sent whenenable_hris on.- API defaults disagree with the UI.
stepsdefaults to 50 server-side but 20 in the browser;inpainting_fillto "fill" rather than "original";inpaint_full_resto "only masked" rather than "whole picture". The UI values are sent explicitly so results match what the same settings produce in the browser. - Grid images. A multi-image batch prepends a contact sheet to the raw list.
images()returns only the samples;grid()exposes the sheet. - The data-URI parser is fragile. The server splits data URIs naively and corrupts anything carrying an extra parameter, so images always go out as bare base64.
extra-batch-imagesusesimageList— the only camelCase key in the API, and a trap for any generic snake_case serialiser.- Two different 422 shapes, one itemised and one not;
ValidationExceptionnormalises both. cmd-flagsleaks credentials. It returnsapi_authandgradio_authin plaintext, so this SDK never logs that response — and neither should you.
$sd = SdWebUi::create('http://127.0.0.1:7860')->logger($psrLogger);Requests and responses are logged at debug. Long strings — base64 images, mostly — are replaced with a size marker so logs stay readable, and the cmd-flags response is never logged at all.
Building that context is not free: the body is materialised, decoded, walked and re-encoded, which on a request carrying a base64 image is megabytes of work. PSR-3 offers no way to ask a logger whether debug is enabled, so injecting a framework logger configured at info would pay all of it and discard the result. Say so explicitly:
$sd->logger($psrLogger)->debugLogging(false);Nothing is built after that. With no logger injected at all the cost is already skipped.
composer ci # lint + stan + test
SDWEBUI_URL=http://127.0.0.1:7860 composer test:integration # needs a live WebUIUnit tests use a mock HTTP client and never touch the network.
MIT. See LICENSE.