Search by

photos-sh / photos

vallic

PHP client for the Photos.sh API: background removal, resizing, upscaling, object and text removal, marketplace checks and fixes, barcode, text and label reading.

v1.0.0 2026-11-13 11:15 UTC

This package is auto-updated.

Last update: 2026-11-13 11:19:46 UTC


README

The PHP client for the Photos.sh API: image tools for product photos. Background removal and staging, resizing and compression, AI upscaling, object and text removal, light and colour fixes, cropping, watermarks, SVG tracing, clean-up and colourising, face tools and identity photos, quality and marketplace checks with auto-fix, barcode, text and label reading, captions and palettes, presets, and batch jobs with signed webhooks.

  • PHP 8.3 or later with the json extension. The curl extension is needed only for the default transport, not when you pass your own. No other dependencies.
  • The HTTP layer is a one-method interface, so it can run on wp_remote_request, Guzzle or anything else.

Install

composer require photos-sh/photos

Quick start

use PhotosSh\Photos;

$photos = new Photos('your-api-key'); // or set PHOTOS_API_KEY

$cutout = $photos->removeBackground('shoe.jpg');
$cutout->save('shoe.png');

echo $cutout->width, 'x', $cutout->height, ' ', $cutout->contentType, "\n";

Create an API key on your organisation's API keys page in the Photos.sh console.

Images in

Wherever a method takes an image, pass a file path, or an Input made from bytes, a stream or a path. A plain string always means a path.

use PhotosSh\Input;

$photos->resize('photos/shoe.jpg', width: 800);
$photos->resize(Input::fromPath('photos/shoe.jpg'), width: 800);
$photos->resize(Input::fromBytes($bytes, 'shoe.jpg'), width: 800);
$photos->resize(Input::fromStream(fopen('php://stdin', 'rb')), width: 800);

Accepted formats: JPEG, PNG, WebP, GIF, TIFF, BMP, and HEIC/HEIF and AVIF where the server supports them.

Images out

Methods that return an image give an ImageResult:

$result = $photos->resize('shoe.jpg', width: 800, format: 'webp');

$result->bytes;        // the encoded image
$result->contentType;  // "image/webp"
$result->width;        // 800
$result->height;       // 600
$result->quality;      // quality used for a lossy format, or null
$result->serverTiming; // the raw Server-Timing header, or null
$result->extension();  // "webp"
$result->save('shoe.webp');

Where the API says more about the image, the method returns a subclass of ImageResult with those values: removeBackground, removeText, fix, crop, vectorize, blurFaces, idPhoto, select and restoreFaces.

Options

Options are named arguments. One you leave out is not sent, and the API's default applies.

Edit

Remove the background

$result = $photos->removeBackground(
    'shoe.jpg',
    format: 'png',          // png (default), jpg, webp, avif
    quality: 90,            // 1-100, lossy formats
    background: '#ffffff',  // composite onto a colour; omit for transparency
    crop: true,             // crop to the subject
    padding: 20,            // margin around the subject when cropping, px
    mask: false,            // true: return the greyscale alpha mask
);

The same call can stage the cutout: put the subject on a canvas, add a shadow or a reflection, and choose what is behind it.

$result = $photos->removeBackground(
    'shoe.jpg',
    width: 1200,            // the canvas; needs height
    height: 1200,
    fill: 0.85,             // how much of the canvas the subject may take, 0.05-1
    align: 'bottom',        // center (default), top, bottom
    shadow: 'contact',      // none (default), contact, drop
    reflection: false,      // true: a fading mirror image under the subject
    background: '#f4f4f4',  // or blur: 40, or backgroundImage: 'studio.jpg'
);

if ($result->subjectFound === false) {
    echo "No subject in the photo: the canvas is empty.\n";
}

Behind the subject goes at most one of: a colour (background), an image (backgroundImage, a path or an Input, scaled to cover the result), or the photo's own background, blurred (blur, 1 to 100). crop does not go with a canvas or with blur, and mask takes none of these options.

subjectFound is false when a canvas was asked for and the photo has no subject. In every other case it is null: the API does not say.

Resize, convert and compress

$result = $photos->resize(
    'shoe.jpg',
    width: 1200,
    height: 1200,
    fit: 'contain',      // contain (default), cover, fill
    enlarge: false,      // allow scaling up with contain
    format: 'jpg',
    quality: 85,
    maxBytes: 300_000,   // lower the quality until the file fits
    background: 'white', // flatten transparency onto this colour
    trim: true,          // cut away an even border first
    trimTolerance: 10,   // how far a pixel may differ from the border's colour, 0-255
    dpi: 300,            // print resolution to write into a JPEG or PNG
);

Upscale

$result = $photos->upscale('small.jpg', scale: 2, format: 'jpg', quality: 90); // scale 1-4, default 4

Erase objects

Give the area as rectangles ([x, y, width, height]), as a mask image the size of the photo (white = remove), or both.

$result = $photos->erase('room.jpg', rects: [[40, 60, 200, 120], [500, 80, 64, 64]]);
$result = $photos->erase('room.jpg', mask: 'room-mask.png', format: 'jpg', quality: 90);

Remove text, watermarks and captions

$result = $photos->removeText(
    'listing.jpg',
    region: 'background',    // background (default: text off the product) or all
    matching: 'sale|€|www\.', // only lines matching this regular expression
    format: 'jpg',
    quality: 90,
);

echo $result->removed, ' removed, ', $result->kept, " kept\n";
$result->save('clean.jpg');

Fix light and colour

$result = $photos->enhance('shoe.jpg');  // automatic: levels and white balance

$result = $photos->enhance(
    'shoe.jpg',
    auto: true,       // keep the automatic part beside the settings below
    brightness: 10,   // -100 to 100; so are contrast, saturation and temperature
    contrast: 5,
    saturation: -20,  // -100 is black and white
    temperature: 15,  // below zero is cooler, above is warmer
    sharpen: 30,      // 0 to 100
    format: 'jpg',
    quality: 90,
);

With any setting, only what you give is applied. levels: true and whiteBalance: true turn on one half of the automatic part.

Crop to a shape

Finds the subject and crops the photo around it, keeping the background.

$result = $photos->crop('shoe.jpg', aspect: '4:5');  // the largest 4:5 crop, moved to hold the subject

$result = $photos->crop(
    'shoe.jpg',
    aspect: '1:1',   // width:height, or a number such as 1.5
    zoom: true,      // the smallest crop that holds the subject and its padding
    padding: 0.1,    // room around the subject, as a fraction of its longest side
    width: 800,      // scale the crop to this size
    height: 800,
);

// A portrait or an avatar: crop around the faces.
$avatar = $photos->crop('team.jpg', aspect: '1:1', focus: 'face', zoom: true, padding: 0.6);

if ($result->subjectFound === false) {
    echo "No subject found: the crop is centred.\n";
}

Add a logo or text

Give text or a watermark image (a path or an Input), not both.

$result = $photos->watermark('shoe.jpg', text: '© Example Shop', color: 'white', position: 'bottom-right');

$result = $photos->watermark(
    'shoe.jpg',
    watermark: 'logo.png',  // a PNG with transparency works best
    position: 'center',     // top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right (default)
    scale: 25,              // the mark's width, % of the photo's width
    opacity: 50,            // 1-100
    margin: 3,              // distance from the edges, % of the photo's shorter side
    tile: false,            // true: repeat the mark across the whole photo
);

Text is drawn in a bold sans-serif with Latin, Greek and Cyrillic letters.

Expand the canvas

$result = $photos->expand('shoe.jpg', aspect: '16:9');          // to a shape, the photo centred
$result = $photos->expand('shoe.jpg', left: 200, right: 200);   // by pixels on each side; also top, bottom
$result = $photos->expand('shoe.jpg', top: 100, fill: 'white'); // a flat colour in place of inpainting

The new area continues the photo by inpainting, which suits plain and softly textured backgrounds. It does not invent content: a subject cut off at the edge stays cut off. Each side can grow by at most the photo's own width or height.

Trace into SVG

For logos, icons and flat drawings. A photograph traces into thousands of paths and a large file.

$svg = $photos->vectorize(
    'logo.png',
    mode: 'color',     // color (default), mono
    curves: 'spline',  // spline (default), polygon, pixel
    detail: 8,         // 1-8: lower merges similar shades into fewer shapes
    speckle: 4,        // drop areas smaller than this many pixels across
);

$svg->save('logo.svg');         // $svg->bytes is the SVG's text
echo $svg->paths, " paths\n";

Hide faces

$result = $photos->blurFaces(
    'street.jpg',
    mode: 'blur',    // blur (default), pixelate, fill
    strength: 50,    // how strong the blur or how coarse the mosaic, 1-100
    padding: 0.15,   // how far past the face's box the hidden area reaches
    minScore: 0.6,   // lower finds more faces, and more things that are not
);
$result = $photos->blurFaces('street.jpg', mode: 'fill', color: 'black');

echo $result->faces, " faces hidden\n";

Check faces before relying on the result: a face that was not found still shows. Only fill leaves nothing of a face.

Identity photos

Frames a portrait for an identity document: levels the head, scales it to the document's head size, and crops to the document's photo size. Give a document from idPhotos(), or the sizes yourself.

foreach ($photos->idPhotos() as $document) {
    echo $document['name'], ': ', $document['width_mm'], ' x ', $document['height_mm'], " mm\n";
}

$result = $photos->idPhoto('portrait.jpg', document: 'us-passport');

$result = $photos->idPhoto(
    'portrait.jpg',
    widthMm: 35,          // the sizes yourself, in place of a document
    heightMm: 45,
    headMinMm: 32,        // chin to crown
    headMaxMm: 36,
    dpi: 300,
    background: 'white',  // keep (default), document, or a colour
);

echo $result->passed ? 'nothing found' : implode(', ', $result->findings), "\n";
echo $result->headMm, " mm from chin to crown\n";

idPhoto keeps the photo's own background by default, because passport authorities do not accept edited photos. Each document's alteration quotes what its authority says.

passed means the tool found none of the faults it can see (head_tilted, not_facing_camera, head_cut_off, not_enough_room, low_resolution, background_not_plain). It is not an approval of the photo. A photo with no face, or more than one, is refused (ApiException::UNPROCESSABLE).

Remove noise and JPEG artefacts

$result = $photos->denoise('grainy.jpg', strength: 100);  // 1-100: lower blends back towards the original

denoise is slow without a GPU and takes photos up to 12 megapixels.

Colour a black-and-white photo

$result = $photos->colorize('old.jpg', strength: 100);  // 1-100: lower is paler

colorize guesses the colours: it cannot know what colour a dress or a car was.

Select an object

Cuts out the object at a point or in a box. Coordinates are in the photo's pixels.

$result = $photos->select('desk.jpg', points: [[420, 310]]);

$result = $photos->select(
    'desk.jpg',
    points: [[420, 310], [455, 290]],  // points on the object, each [x, y]
    exclude: [[300, 500]],             // points that must stay outside it
    box: [350, 200, 220, 260],         // a box around it: x, y, width, height
    pick: 'largest',                   // auto (default), smallest, largest
    crop: true,                        // crop to the object
    padding: 10,
    background: 'white',               // omit for transparency
);

echo $result->score, "\n";  // how well the model thinks the mask fits, 0 to 1

With mask: true the result is the object's mask, which erase takes to remove the object:

use PhotosSh\Input;

$mask = $photos->select('desk.jpg', points: [[420, 310]], mask: true);
$clean = $photos->erase('desk.jpg', mask: Input::fromBytes($mask->bytes, 'mask.png'));

When nothing is found at the points, the call is refused (ApiException::UNPROCESSABLE).

Restore faces

For old, blurred, small or noisy photos. Only the faces change.

$result = $photos->restoreFaces('old-family.jpg', strength: 100, minScore: 0.6);

echo $result->faces, ' restored, ', $result->facesSkipped, " left alone\n";

restoreFaces invents the detail it draws, so do not use it where the face has to be exactly the person's own, such as an identity document or evidence.

Presets and recipes

foreach ($photos->presets() as $preset) {
    echo $preset['name'], ': ', $preset['description'], "\n";
}

$result = $photos->run('shoe.jpg', preset: 'marketplace');
$result = $photos->run('shoe.jpg', recipe: file_get_contents('my-recipe.toml'));

Check and fix

Check a photo

$check = $photos->check('shoe.jpg');                         // quality only: fast
$check = $photos->check('shoe.jpg', marketplace: 'amazon');  // plus Amazon's rules
$check = $photos->check('shoe.jpg', subject: true, text: true);

echo $check['metrics']['sharpness'] ?? '?', "\n";
foreach ($check['findings'] as $finding) {
    echo $finding['severity'], ' ', $finding['code'], ': ', $finding['message'], "\n";
}
if (isset($check['marketplace']) && !$check['marketplace']['passed']) {
    echo "Would be rejected.\n";
}

Fix a photo for a marketplace

$fixed = $photos->fix('shoe.jpg', marketplace: 'amazon');
$fixed->save('shoe-amazon.' . $fixed->extension());

if (!$fixed->passed) {
    echo 'Still failing: ', implode(', ', $fixed->findings), "\n";
}

Marketplace rules

foreach ($photos->marketplaces() as $marketplace) {
    echo $marketplace['name'], ' (', $marketplace['title'], '), checked ', $marketplace['checked'], "\n";
}

Read

Barcodes

$scan = $photos->readBarcodes('box.jpg');                    // EAN-13, UPC-A, ITF-14
$scan = $photos->readBarcodes('box.jpg', allFormats: true, engine: 'auto');  // plus QR, Code 128, ...

foreach ($scan['barcodes'] as $barcode) {
    echo $barcode['format'], ' ', $barcode['value'], "\n";
}

Text

$text = $photos->readText('label.jpg');

foreach ($text['lines'] as $line) {
    echo $line['text'], ' (', round($line['score'] * 100), "%)\n";
}

Label details

One to eight photos of one product:

$reading = $photos->readLabel(['front.jpg', 'back.jpg']);

echo $reading['label']['gtin'] ?? 'no GTIN', "\n";
print_r($reading['label']);   // net quantity, ingredients, allergens, dates, ...

Match a product in your catalogue

One to eight photos of one product, matched against one of your organisation's catalogues. A catalogue is a spreadsheet of your products (product_id, title, and where you have them brand, barcode, sku), uploaded on the console's Catalogues page. Catalogue matching is turned on per organisation: until it is on for yours and a catalogue is uploaded, the API answers 404 not_found. The catalogue's name may be left out when you have exactly one.

Each catalogue is one kind of product, chosen when it is uploaded: any product, food and drink, cosmetics, pharmacy and supplements, household and pet, electronics, or wine and spirits. Each kind also compares its own values with the label, so the right size or strength of a product sold in several is found.

Each candidate's evidence says what agreed beyond the name: barcode, barcode_text, sku, brand, and by the catalogue's kind quantity, pack, spf, strength, count, spec, model, cask_number, age, age_weak, vintage, abv, size. label holds the values read off the photos.

$match = $photos->identify(['front.jpg', 'back.jpg'], catalogue: 'shoes', top: 3);

foreach ($match['candidates'] as $candidate) {
    echo $candidate['title'], ' ', $candidate['score'], ' [', implode(', ', $candidate['evidence']), "]\n";
}

Colours

The main colours of an image, the most common first. Transparent pixels do not count, so a cutout's palette is the subject's.

$palette = $photos->palette('shoe.jpg', colors: 5);  // 1-16 colours, 5 by default

foreach ($palette['colors'] as $color) {
    echo $color['hex'], ' ', round($color['share'] * 100), "%\n";  // also: rgb, as [r, g, b]
}

Faces

Where the faces are. It finds faces; it does not recognise who anyone is.

$found = $photos->findFaces('team.jpg', minScore: 0.6);

foreach ($found['faces'] as $face) {
    $box = $face['box'];  // x, y, width, height, in the photo's pixels
    echo round($box['width']), 'x', round($box['height']), ' at ', round($box['x']), ',', round($box['y']), "\n";

    [$x, $y] = $face['landmarks']['nose'];  // also: right_eye, left_eye, mouth_right, mouth_left
    echo $face['score'], ' ', $face['roll'], "\n";  // how sure the model is; the head's tilt in degrees
}

"Right" and "left" are the person's own, so right_eye is the one on the left of the photo.

Caption

$caption = $photos->caption('shoe.jpg', detail: 'short');  // short (default), detailed, paragraph

echo $caption['caption'], "\n";

caption can be wrong and writes English only, so read the text before you publish it.

Account

$account = $photos->account();
echo $account['credits'] ?? 'unlimited', "\n";  // also: account, key, prices

echo $photos->health(), "\n";  // "ok", "loading" or "failed"

Batch jobs

A job applies one operation to many images in the background. Inputs are URLs the service fetches (strings starting with http:// or https://), files to upload (any other string is a path), or Inputs. params are the operation's options under their API names.

$job = $photos->createJob(
    'remove-background',
    ['https://example.com/photos/1.jpg', 'https://example.com/photos/2.jpg'],
    params: ['format' => 'png', 'crop' => true],
    webhookUrl: 'https://example.com/hooks/photos',  // optional
);

$job = $photos->waitForJob($job['id'], pollInterval: 2.0, timeout: 600.0);

echo $job['counts']['done'], ' done, ', $job['counts']['failed'], " failed\n";

foreach ($job['items'] as $n => $item) {
    if ($item['status'] === 'done') {
        $photos->jobItemResult($job['id'], $n)->save("result-$n.png");
    } else {
        echo $item['input'], ': ', $item['error']['message'] ?? 'failed', "\n";
    }
}

$photos->deleteJob($job['id']);

Uploads, and a recipe:

use PhotosSh\Input;

$job = $photos->createJob('resize', ['a.jpg', 'b.jpg', Input::fromBytes($bytes, 'c.jpg')], params: ['width' => 800]);
$job = $photos->createJob('run', ['a.jpg'], recipe: file_get_contents('my-recipe.toml'));

The operations are run, remove-background, resize, upscale, ocr, barcodes, check, fix, remove-text, label, enhance, crop, palette, watermark, expand, vectorize, faces, blur-faces, id-photo, denoise, colorize, select, restore-faces and caption. A job item is one image, so a watermark job takes text only and a remove-background job no background image.

An option that repeats is a list. For select, each point is the string "x,y":

$job = $photos->createJob('select', ['https://example.com/photos/1.jpg'], params: [
    'point' => ['420,310', '455,290'],
    'exclude' => ['300,500'],
    'mask' => true,
]);

The other job methods:

$job = $photos->getJob($id);     // status, counts, items
$jobs = $photos->listJobs();     // recent jobs, newest first

$item = $photos->jobItemResult($id, 0);
if ($item->isImage()) {
    $image = $item->image();     // an ImageResult
} else {
    $data = $item->json();       // check, barcodes, ocr, label, palette, faces, caption
}
$item->body;                     // the raw body, whatever it is
$item->contentType;
$item->headers;

The items of remove-text and fix jobs carry the same extras as the synchronous methods:

$clean = $photos->jobItemResult($id, 0)->removeText();  // a RemoveTextResult
echo $clean->removed, ' removed, ', $clean->kept, " kept\n";

$fixed = $photos->jobItemResult($id, 0)->fix();         // a FixResult
echo $fixed->passed ? 'passes' : implode(', ', $fixed->findings), "\n";

So do the items of remove-background, crop, vectorize, blur-faces, id-photo, select and restore-faces jobs, through removeBackground(), crop(), vectorize(), blurFaces(), idPhoto(), select() and restoreFaces():

$hidden = $photos->jobItemResult($id, 0)->blurFaces();  // a BlurFacesResult
echo $hidden->faces, " faces hidden\n";

$svg = $photos->jobItemResult($id, 0)->vectorize();     // a VectorizeResult
$svg->save('logo.svg');

They throw a PhotosException when the result does not carry those headers (the job ran another operation), rather than report zeros. The exception is removeBackground(), whose subjectFound is null when the API did not say. The same headers are in the job itself, as $job['items'][$n]['headers'] with lower-case names.

A failed item has an error with a code and message; besides the usual codes it can be input_unavailable (the URL could not be fetched).

waitForJob() throws a WaitTimeoutException when the job has not completed in time; the job keeps running. A job is completed once every item was attempted: look at counts and each item's status for failures.

Webhooks

Pass webhookUrl when creating a job and Photos.sh posts a signed job.completed event to it. Verify the signature before trusting the body. The helper needs no client:

use PhotosSh\Exception\WebhookException;
use PhotosSh\Webhook;

$payload = (string) file_get_contents('php://input');  // the raw body, untouched
$signature = $_SERVER['HTTP_PHOTOS_SIGNATURE'] ?? '';
$secret = (string) getenv('PHOTOS_WEBHOOK_SECRET');    // from $photos->webhookSecret()['secret']

try {
    $event = Webhook::verify($payload, $signature, $secret);
} catch (WebhookException $e) {
    http_response_code(400);
    exit;
}

if ($event['type'] === 'job.completed') {
    $job = $event['job'] ?? [];  // the same shape getJob() returns
}

verify() rejects deliveries whose timestamp is more than 300 seconds from now; change that with tolerance:. Your signing key:

$secret = $photos->webhookSecret()['secret'];

Errors

Everything the library throws extends PhotosSh\Exception\PhotosException.

Exception When
ApiException The API answered with an error. Has status, errorCode, retryAfter, body, and the API's message as getMessage().
UnauthorizedException 401 unauthorized: missing or invalid API key.
PaymentRequiredException 402 payment_required: out of credits.
AccountSuspendedException 403 account_suspended.
NotFoundException 404 not_found: no such job, or no result for that item.
BusyException 503 busy: all workers busy, after the client's own retries.
ConnectionException No response: DNS, refused or broken connection.
TimeoutException The request timed out (a ConnectionException).
WaitTimeoutException waitForJob() gave up waiting.
WebhookException A webhook delivery failed verification.
InvalidInputException The call cannot be made: no API key, an unreadable file, no images.

The five ApiException subclasses cover the cases worth their own catch. For the rest, compare errorCode with the constants on ApiException: BAD_REQUEST, PAYLOAD_TOO_LARGE, UNSUPPORTED_FORMAT, UNPROCESSABLE, MODEL_UNAVAILABLE, INTERNAL.

use PhotosSh\Exception\ApiException;
use PhotosSh\Exception\BusyException;
use PhotosSh\Exception\ConnectionException;
use PhotosSh\Exception\PaymentRequiredException;

try {
    $result = $photos->upscale('small.jpg');
} catch (PaymentRequiredException $e) {
    // Out of credits: top up.
} catch (BusyException $e) {
    // Try again in $e->retryAfter seconds.
} catch (ApiException $e) {
    if ($e->errorCode === ApiException::UNSUPPORTED_FORMAT) {
        // Not an image format the service reads.
    }
    error_log("Photos.sh said {$e->status} {$e->errorCode}: {$e->getMessage()}");
} catch (ConnectionException $e) {
    // The service could not be reached, or the request timed out.
}

Configuration

use PhotosSh\Photos;
use PhotosSh\Transport\CurlTransport;

$photos = new Photos(
    apiKey: 'your-api-key',             // default: the PHOTOS_API_KEY environment variable
    baseUrl: 'https://api.photos.sh',   // default: PHOTOS_BASE_URL, then https://api.photos.sh
    timeout: 120.0,                     // seconds for one request
    maxRetries: 2,                      // extra attempts; 0 turns retries off
    transport: new CurlTransport([CURLOPT_PROXY => 'http://proxy.internal:3128']),
);

Retries. A 503 busy (or a 503 that is not the API's own error, such as a proxy's) is retried after the Retry-After the server gives (at most 30 seconds), or after 1 and then 2 seconds. A failed connection is retried the same way, except when creating a job (so that a job is never created twice). Timeouts, 503 model_unavailable and all other statuses are not retried.

Another HTTP client

Implement PhotosSh\Transport\Transport: one method that sends a ready-made request (method, URL, headers, body string, timeout) and returns the status, headers and body. Do not follow redirects or retry, return error statuses as responses, and throw ConnectionException (or TimeoutException) when no response arrives. For WordPress:

use PhotosSh\Exception\ConnectionException;
use PhotosSh\Photos;
use PhotosSh\Transport\Request;
use PhotosSh\Transport\Response;
use PhotosSh\Transport\Transport;

final class WordPressTransport implements Transport
{
    public function send(Request $request): Response
    {
        $result = wp_remote_request($request->url, [
            'method' => $request->method,
            'headers' => $request->headers,
            'body' => $request->body,
            'timeout' => $request->timeout,
            'redirection' => 0,
        ]);
        if (is_wp_error($result)) {
            throw new ConnectionException($result->get_error_message());
        }

        $headers = [];
        foreach (wp_remote_retrieve_headers($result) as $name => $value) {
            $headers[$name] = is_array($value) ? implode(', ', $value) : $value;
        }

        return new Response(
            (int) wp_remote_retrieve_response_code($result),
            $headers,
            wp_remote_retrieve_body($result),
        );
    }
}

$photos = new Photos(transport: new WordPressTransport());

Development

composer install
composer test        # unit tests and the cURL transport against PHP's built-in server
composer stan        # PHPStan, level max
composer cs          # PHP-CS-Fixer (PER-CS), dry run; composer cs:fix applies it

Integration tests run against a real engine and are skipped unless both variables are set:

PHOTOS_TEST_BASE_URL=http://127.0.0.1:8098/api PHOTOS_TEST_API_KEY=... composer test:integration

License

MIT. See LICENSE.