Skip to content

PHP client ​

The official library for the Search API and the Catalog API. It handles batching, partial failures, retries and a safe full sync.

The client has no framework dependency: HTTP goes through a PSR-18 client and PSR-17 factories — either the ones you pass in, or the ones discovery finds in your project — and logging is optional through PSR-3. It requires PHP 8.1 or newer.

Installation ​

bash
composer require getmetty/metty-php

If your project does not have a PSR-18 client yet, install any implementation:

bash
composer require symfony/http-client nyholm/psr7

Configuration ​

php
use Metty\Client\MettyClient;

$client = MettyClient::create(
    publicKey: '<PUBLIC_API_KEY>',   // reads
    secretKey: '<SECRET_API_KEY>',   // catalog writes; server side only
);

The addresses of both APIs are defaults, so you do not have to state them. Pass only the key you actually need: a client with pk_ can only read, a client with sk_ can only write. Swapped keys are rejected at construction time so that a secret cannot end up in a URL.

create() finds the PSR-18 client and the PSR-17 factories through discovery, and discovery does not guarantee a timeout. In an application that already has them in its container, wire them in yourself — then nothing depends on what discovery happens to find, and a PSR-3 logger can be passed at the same time:

php
use Metty\Client\Configuration;
use Metty\Client\MettyClient;

$client = new MettyClient(
    new Configuration(
        publicKey: '<PUBLIC_API_KEY>',
        secretKey: '<SECRET_API_KEY>',
        maxRetries: 3,
    ),
    $httpClient,       // Psr\Http\Client\ClientInterface
    $requestFactory,   // Psr\Http\Message\RequestFactoryInterface
    $streamFactory,    // Psr\Http\Message\StreamFactoryInterface
    $logger,           // Psr\Log\LoggerInterface, optional
);

Every argument after the configuration is optional and falls back to discovery, so you can hand over only the logger. Without a logger the client logs nothing.

Timeouts ​

Autodiscovery (php-http/discovery) picks whatever client it finds, and none of the implementations guarantee a timeout — with the wrong one a request can hang until the PHP process is killed. Configure the connect and total timeouts yourself and pass the ready-made client in:

php
use Metty\Client\Configuration;
use Metty\Client\MettyClient;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;

$psr17 = new Psr17Factory();
$httpClient = new Psr18Client(
    HttpClient::create(['timeout' => 10, 'max_duration' => 30]),
    $psr17,
    $psr17,
);

$client = new MettyClient(
    new Configuration('<PUBLIC_API_KEY>', '<SECRET_API_KEY>'),
    $httpClient,
    $psr17,
    $psr17,
);

timeout is the idle timeout of the connection, max_duration the ceiling for the whole request; Guzzle calls the same pair connect_timeout and timeout. They bound a single attempt — a retried call also waits between the attempts, at most 60 s per retry when the server sends Retry-After, otherwise a short backoff. search() and suggest() never retry a 429; they throw it at once.

php
use Metty\Client\Search\SearchQuery;

$response = $client->search()->search(
    SearchQuery::for('vŕtačka')
        ->facet('farba', 'modrá')
        ->facet('farba', 'čierna')          // the same field twice means OR
        ->category('Náradie > Vŕtačky')
        ->priceRange(100, 200)
        ->sortBy('price_asc')
        ->withSections('facets', 'categories', 'suggestions')
        ->perPage(24)
        ->page(2),
);

foreach ($response->products as $product) {
    echo $product->id, ' ', $product->name, ' ', $product->price, PHP_EOL;
}

echo $response->total, ' results across ', $response->pages, ' pages';

facets, categories, priceRange and suggestions are populated only when withSections() is used. The highlight field arrives from the server including the [] markers; the client does not compute highlighting.

The client knows about the 200 result window: hasNextPage() respects it and a query outside the window fails before the request is sent rather than as a 422.

php
foreach ($client->search()->searchAll(SearchQuery::for('vŕtačka')) as $product) {
    echo $product->name, PHP_EOL;
}

Autocomplete:

php
$suggest = $client->search()->suggest('vŕta', limit: 8);

$suggest->suggestions;  // [['query' => 'vŕtačka', 'count' => 41], …]
$suggest->products;     // at most 5 compact products

Writing the catalog ​

php
use Metty\Client\Catalog\CatalogProduct;

$result = $client->catalog()->replace([
    CatalogProduct::create('sku-1', 'Príklepová vŕtačka', 'https://eshop.sk/vrtacka',
        price: 129.9, inStock: true, brand: 'Bosch', category: 'Náradie > Vŕtačky',
        params: ['farba' => 'modrá', 'príkon' => '800 W']),
    CatalogProduct::create('sku-2', 'Uhlová brúska', 'https://eshop.sk/bruska', price: 89.5),
]);

if ($result->hasFailures()) {
    foreach ($result->failures() as $failure) {
        echo $failure->id, ': ', $failure->error, ' — ', $failure->message, PHP_EOL;
    }
}

You can hand over a catalog of any size at once — the client splits it into batches of 100 products and merges the results.

replace() replaces a product entirely, patch() changes only the fields you send, and delete(['sku-1']) removes products. With patch() an omitted field differs from a field set to null, so clearing a value is written explicitly:

php
$client->catalog()->patch([
    new CatalogProduct('sku-1', ['price' => 99.0, 'brand' => null]),
]);

Safe full sync ​

php
$outcome = $client->catalog()->synchronize($products);

echo $outcome['commit']['removed'];  // how many stale products were dropped

The client opens a sync, uploads the whole catalog under it and only then commits. If any product fails to upload, the sync is not committed — the commit would delete exactly what failed — and a SyncIncompleteException is thrown. The sync stays open, so it can be finished:

php
use Metty\Client\Exception\SyncIncompleteException;

try {
    $client->catalog()->synchronize($products);
} catch (SyncIncompleteException $exception) {
    $retry = $client->catalog()->replace($products, $exception->syncId);

    if ($retry->hasFailures()) {
        foreach ($retry->failures() as $failure) {
            echo $failure->id, ': ', $failure->error, ' — ', $failure->message, PHP_EOL;
        }

        throw $exception;
    }

    $client->catalog()->commit($exception->syncId);
}

replace() never throws over a single product, so a repeat has to be checked exactly like the first upload: commit only a WriteResult without failures. A commit of a sync in which something failed again would delete precisely the products that failed to upload. An uncommitted sync deletes nothing, so leaving it open and finishing it in the next run is the safe outcome.

Resending the whole snapshot is the simplest repeat, because a product written twice under the same sync stays a single product. It does assume $products can be iterated a second time — a generator that synchronize() has already consumed yields nothing, so read the source again for the repeat. To resend only what failed, take the ids from $exception->result->failures() — every ItemResult carries id, error and message.

An empty snapshot is always rejected by the client. The force: true parameter applies solely to the server-side safeguard that rejects a snapshot covering less than half of the catalog.

Export ​

php
foreach ($client->catalog()->export() as $product) {
    echo $product['id'], PHP_EOL;
}

The client pages automatically, so the loop walks the whole catalog.

Feature overview ​

  • batching according to the server limit of 100 products per batch
  • partial failure handling — the status of every product separately, not one exception for the whole batch
  • server boundary checks — an unknown sort, section or a page outside the window fails locally
  • retries — 429 for catalog calls, honouring Retry-After; search() and suggest() throw a 429 at once (ApiException::isRateLimited()); a server or network error only for methods that are safe to repeat; other 4xx never
  • full sync safeguard — an incomplete snapshot is never committed
  • safe logging — the Authorization header reaches neither the log nor an exception

No idempotency key is needed: the server writes by id, so a repeated batch cannot create duplicates.

Errors ​

exceptionwhen
ConfigurationExceptioninvalid configuration, or a query the server would reject
ApiExceptionthe server returned an error; carries statusCode and errorCode
SyncIncompleteExceptiona full sync did not complete and was not committed
TransportExceptiona network error, or a response that cannot be parsed

All of them implement Metty\Client\Exception\MettyException, so they can be caught together.

Complete usage examples are in the Examples section.

Metty documentation