Skip to content

Repository files navigation

minhyung/sdwebui

CI Latest version PHP version License

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');

Requirements

  • PHP 8.2+, with ext-json and ext-mbstring
  • A WebUI started with --api

Installation

This package deliberately bundles no HTTP client, so you must supply one. Install it alongside:

composer require minhyung/sdwebui symfony/http-client nyholm/psr7

or, with Guzzle:

composer require minhyung/sdwebui guzzlehttp/guzzle

composer require minhyung/sdwebui on its own is not enough. It will install cleanly — php-http/discovery satisfies the PSR implementation requirements on paper — but the first call then fails with a ConfigurationException, 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/sdwebui

That 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

Timeouts — read this first

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
    ])));

Authentication

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-auth by splitting on , then :, so a password containing either character cannot be configured server-side at all.
  • --gradio-auth does not protect /sdapi/v1/*, and a few extension-registered routes (the LoRA listing among them) bypass --api-auth entirely. Do not treat the API as protected just because one of those flags is set.

Generating images

txt2img

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 block

Enums 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().

Hires fix

$sd->txt2img()
    ->prompt('a detailed landscape')
    ->size(512, 512)
    ->hiresFix(scale: 2.0, upscaler: 'R-ESRGAN 4x+', denoisingStrength: 0.4)
    ->send();

img2img and inpainting

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();

Per-request settings

$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]');

Images

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.

Postprocessing

$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();

Reading metadata

$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();

Extensions

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();

Progress

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 id

This 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.

Single-process polling

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/promises

guzzlehttp/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.

Inspecting the server

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.

Error handling

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

Endpoints not modelled here

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();

Hooking into requests

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.

API quirks this SDK handles for you

The WebUI API has a number of sharp edges. These are absorbed so you do not have to think about them:

  • info is JSON inside JSON. Generation responses carry info as an encoded string; it is decoded transparently by $result->info().
  • denoising_strength has no working default on txt2img. The server's schema generator lets the base class's null overwrite the documented 0.75, so hires fix silently does nothing when it is unset. An explicit value is always sent when enable_hr is on.
  • API defaults disagree with the UI. steps defaults to 50 server-side but 20 in the browser; inpainting_fill to "fill" rather than "original"; inpaint_full_res to "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-images uses imageList — 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; ValidationException normalises both.
  • cmd-flags leaks credentials. It returns api_auth and gradio_auth in plaintext, so this SDK never logs that response — and neither should you.

Logging

$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.

Contributing

composer ci                                                   # lint + stan + test
SDWEBUI_URL=http://127.0.0.1:7860 composer test:integration   # needs a live WebUI

Unit tests use a mock HTTP client and never touch the network.

License

MIT. See LICENSE.

About

PHP SDK for Stable Diffusion WebUI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages