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 is the safe outcome: it expires one hour after its last write and the next synchronize() opens a new one. Until then a new full sync fails with an ApiException carrying errorCode sync_in_progress.

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