pobo-builder / php-sdk
Official PHP SDK for Pobo API V2 - product content management and webhooks
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- fakerphp/faker: ^1.23
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
This package is not auto-updated.
Last update: 2026-05-19 14:05:46 UTC
README
Official PHP SDK for Pobo API V2 — product content management and webhooks.
Requirements
- PHP 8.1+
- ext-curl, ext-json
Installation
composer require pobo-builder/php-sdk
Quick Start
use Pobo\Sdk\PoboClient; $client = new PoboClient(apiToken: 'your-api-token');
The token authenticates via Authorization: Bearer ... and is bound to a single e-shop. Get the API token and webhook secret in the Pobo administration under e-shop settings → REST API.
The examples below use only the default language. For multi-language imports and exports see Multilang further down.
Import
Order: parameters → categories → brands → products → blogs.
Parameters
use Pobo\Sdk\DTO\Parameter; use Pobo\Sdk\DTO\ParameterValue; $result = $client->importParameters([ new Parameter(id: 1, name: 'Color', values: [ new ParameterValue(id: 1, value: 'Red'), new ParameterValue(id: 2, value: 'Blue'), ]), ]);
Categories
use Pobo\Sdk\DTO\Category; use Pobo\Sdk\DTO\LocalizedString; $result = $client->importCategories([ new Category( id: 'CAT-001', isVisible: true, name: LocalizedString::create('Electronics'), url: LocalizedString::create('https://example.com/electronics'), ), ]);
Brands
use Pobo\Sdk\DTO\Brand; use Pobo\Sdk\DTO\LocalizedString; $result = $client->importBrands([ new Brand( id: 'BRAND-001', isVisible: true, name: LocalizedString::create('Apple'), url: LocalizedString::create('https://example.com/znacky/apple'), imagePreview: 'https://example.com/brands/apple-logo.png', ), ]);
imagePreview follows the same three-state semantics as Product::brandId:
Value of imagePreview |
Behavior |
|---|---|
| omitted (default) | image_preview is not sent — the server keeps the existing logo |
null |
image_preview is cleared on the brand |
'https://.../logo.png' (string) |
image_preview is set to this value |
Products
use Pobo\Sdk\DTO\Product; use Pobo\Sdk\DTO\LocalizedString; $result = $client->importProducts([ new Product( id: 'PROD-001', isVisible: true, name: LocalizedString::create('iPhone 15'), url: LocalizedString::create('https://example.com/iphone-15'), categoriesIds: ['CAT-001'], parametersIds: [1, 2], brandId: 'BRAND-001', ), ]); if ($result->hasErrors()) { foreach ($result->errors as $error) { echo implode(', ', $error['errors']); } }
Pairing a product with a brand
brandId has three-state semantics:
Value of brandId |
Behavior |
|---|---|
| omitted (default) | brand_id is not sent — the server keeps the existing assignment |
null |
brand is cleared on the product |
'BRAND-001' (string) |
paired with brand.remote_id in Pobo |
The brand referenced by brandId must already exist in Pobo (registered via importBrands); otherwise the product is skipped with "Invalid brand id: ...".
Empty string
brandId: '': the API treats""as a lookup, not a clear — the product is skipped with"Invalid brand id: ". To clear an existing brand pairing, pass an explicitnull. The same applies to entries insidecategoriesIds/parametersIds— the SDK defensively filters empty strings andnullfrom those arrays before sending.
Known V2 limitation — clearing categories or parameters: sending
categoriesIds: [](orparametersIds: []) does not detach existing pivot rows; the server treats an empty/absent array as "no change". V2 currently has no clear-all endpoint for these relations — clearing has to happen via the Pobo admin UI. The 3-state semantic only applies tobrandId.
Blogs
use Pobo\Sdk\DTO\Blog; use Pobo\Sdk\DTO\LocalizedString; $result = $client->importBlogs([ new Blog( id: 'BLOG-001', isVisible: true, name: LocalizedString::create('New Product Launch'), url: LocalizedString::create('https://example.com/blog/new-product'), category: 'news', ), ]);
Per-item errors: validation failures during bulk import (invalid URL, missing language, …) don't raise an exception. They appear in
$result->errors[]while the rest of the batch is processed. Always inspect$result->hasErrors().
Delete
Soft-delete by external ID (max 100 per request):
$client->deleteProducts(['PROD-001', 'PROD-002']); $client->deleteCategories(['CAT-001']); $client->deleteBrands(['BRAND-001']); $client->deleteBlogs(['BLOG-001']);
Soft-deleting a brand does not clear
brand_idon products that reference it. Re-import the products withbrandId: nullif you also want to drop the assignment.
Export
$response = $client->getProducts(page: 1, perPage: 50); foreach ($response->data as $product) { echo $product->id, ': ', $product->name->getDefault(), "\n"; } // Or iterate everything (handles pagination automatically): foreach ($client->iterateProducts() as $product) { // ... }
The same shape works for getCategories / getBrands / getBlogs (and their iterate* variants).
isEdited: when not provided, the server defaults totrueand returns only items that have been edited in Pobo (is_loaded = true). To export everything, passisEdited: falseexplicitly.
Filter by last update
$since = new DateTime('2024-01-01 00:00:00'); $response = $client->getProducts(lastUpdateFrom: $since);
Generated content (HTML, marketplace, …)
By default the export returns only content.html. Use include to request additional content:
use Pobo\Sdk\Enum\IncludeContent; foreach ($client->iterateProducts(include: [IncludeContent::MARKETPLACE]) as $product) { $html = $product->content?->getHtmlDefault(); $marketplaceHtml = $product->content?->getMarketplaceDefault(); }
| Value | Description | Available for |
|---|---|---|
marketplace |
HTML for marketplace (no custom CSS) | product, category, brand, blog |
nested |
Raw widget JSON | product, category, brand, blog |
site_link |
Anchor navigation on H2 headings | product, brand, blog |
rich_snippet |
JSON-LD structured data (FAQPage) | product, category, brand, blog |
site_link requires enable_site_link and rich_snippet requires enable_rich_snippet to be enabled on the e-shop in Pobo administration.
Multilang
Pobo supports 7 languages: default, cs, sk, en, de, pl, hu. default is a separate virtual locale used as fallback content — it is not an alias for cs or any other language.
| Code | Language |
|---|---|
default |
Default (required) |
cs |
Czech |
sk |
Slovak |
en |
English |
de |
German |
pl |
Polish |
hu |
Hungarian |
Importing translations
Use LocalizedString::withTranslation() to chain languages on any localized field:
use Pobo\Sdk\DTO\LocalizedString; use Pobo\Sdk\Enum\Language; $name = LocalizedString::create('iPhone 15') // default ->withTranslation(Language::CS, 'iPhone 15 CZ') ->withTranslation(Language::SK, 'iPhone 15 SK') ->withTranslation(Language::EN, 'iPhone 15'); $name->getDefault(); // 'iPhone 15' $name->get(Language::CS); // 'iPhone 15 CZ' $name->toArray(); // ['default' => '...', 'cs' => '...', 'sk' => '...', 'en' => '...']
Pass localized strings to any DTO that accepts them — products, categories, brands, blogs:
use Pobo\Sdk\DTO\Product; use Pobo\Sdk\DTO\LocalizedString; use Pobo\Sdk\Enum\Language; new Product( id: 'PROD-001', isVisible: true, name: LocalizedString::create('iPhone 15') ->withTranslation(Language::CS, 'iPhone 15 CZ') ->withTranslation(Language::SK, 'iPhone 15 SK'), url: LocalizedString::create('https://example.com/iphone-15') ->withTranslation(Language::CS, 'https://example.com/cs/iphone-15') ->withTranslation(Language::SK, 'https://example.com/sk/iphone-15'), shortDescription: LocalizedString::create('Latest iPhone model') ->withTranslation(Language::CS, 'Nejnovější model iPhone') ->withTranslation(Language::SK, 'Najnovší model iPhone'), description: LocalizedString::create('<p>The best iPhone ever.</p>') ->withTranslation(Language::CS, '<p>Nejlepší iPhone vůbec.</p>') ->withTranslation(Language::SK, '<p>Najlepší iPhone vôbec.</p>'), );
Validation rules
The API enforces a "language consistency" rule across multilang fields:
name.defaultandurl.defaultare always required.- If any field on an item carries a translation key (e.g.
sk), thenname.{lang}andurl.{lang}become required for that item. - Other fields (
short_description,description,seo_title,seo_description) may havenullvalues, but the language key must be present if the language is used elsewhere on the item. - All
url.*values must start withhttps://. - Records that fail validation are skipped, not aborted — the response reports them in
errors[]. The rest of the batch is imported. - These rules apply identically to products, categories, brands and blogs.
Update behavior
When you re-import an item:
- Languages present in the request stay active (
*_meta.is_active = true). - Languages missing from the request are soft-deleted (
*_meta.is_active = false).
Always re-send every language you want to keep visible.
Filtering languages on export
By default, only default is returned. Use the lang parameter to request specific languages:
use Pobo\Sdk\Enum\Language; // All 7 languages $response = $client->getProducts(lang: [Language::ALL]); // Specific languages $response = $client->getProducts(lang: [Language::DEFAULT, Language::CS, Language::SK]); foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) { echo $product->name->get(Language::CS); echo $product->content?->getHtml(Language::CS); }
The lang parameter filters every multilang field in the response — name, description, url, …, plus content.html, content.marketplace, site_link.*, rich_snippet.*. Invalid language values are silently ignored.
Reading translations
use Pobo\Sdk\Enum\Language; foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) { $product->name->getDefault(); // default value $product->name->get(Language::CS); // Czech variant (or null) $product->name->toArray(); // ['default' => '...', 'cs' => '...', ...] // Same accessors on content / site_link / rich_snippet: $product->content?->getHtml(Language::CS); $product->content?->getMarketplace(Language::CS); $product->siteLink?->getList(Language::CS); $product->richSnippet?->getJson(Language::CS); }
Webhook Handler
Pobo can notify your application when content changes in the administration. Register a webhook URL in the Pobo e-shop settings; Pobo POSTs to it with HMAC-SHA256 signed requests.
Supported events
| Event | Constant | Fired when |
|---|---|---|
Products.create |
WebhookEvent::PRODUCTS_CREATE |
a product was created |
Products.update |
WebhookEvent::PRODUCTS_UPDATE |
a product was edited |
Products.delete |
WebhookEvent::PRODUCTS_DELETE |
a product was soft-deleted |
Categories.create |
WebhookEvent::CATEGORIES_CREATE |
a category was created |
Categories.update |
WebhookEvent::CATEGORIES_UPDATE |
a category was edited |
Categories.delete |
WebhookEvent::CATEGORIES_DELETE |
a category was soft-deleted |
Brands.create |
WebhookEvent::BRANDS_CREATE |
a brand was created |
Brands.update |
WebhookEvent::BRANDS_UPDATE |
a brand was edited |
Brands.delete |
WebhookEvent::BRANDS_DELETE |
a brand was soft-deleted |
Blogs.create |
WebhookEvent::BLOGS_CREATE |
a blog was created |
Blogs.update |
WebhookEvent::BLOGS_UPDATE |
a blog was edited |
Blogs.delete |
WebhookEvent::BLOGS_DELETE |
a blog was soft-deleted |
The enum exposes isCreate() / isUpdate() / isDelete() helpers if you only need to branch on the lifecycle action regardless of entity type.
Note: the API also defines
Languages.create/update/deleteevents. The SDK does not expose them yet — payloads carrying these events will throwWebhookException::unknownEvent().
The webhook is a notification only — it does not carry the changed entities. Use it as a trigger to re-sync via the export endpoints (typically combined with lastUpdateFrom).
Basic usage
use Pobo\Sdk\WebhookHandler; use Pobo\Sdk\Enum\WebhookEvent; use Pobo\Sdk\Exception\WebhookException; $handler = new WebhookHandler(webhookSecret: 'your-webhook-secret'); try { $payload = $handler->handleFromGlobals(); match ($payload->event) { WebhookEvent::PRODUCTS_CREATE, WebhookEvent::PRODUCTS_UPDATE, WebhookEvent::PRODUCTS_DELETE => syncProducts($client), WebhookEvent::CATEGORIES_CREATE, WebhookEvent::CATEGORIES_UPDATE, WebhookEvent::CATEGORIES_DELETE => syncCategories($client), WebhookEvent::BRANDS_CREATE, WebhookEvent::BRANDS_UPDATE, WebhookEvent::BRANDS_DELETE => syncBrands($client), WebhookEvent::BLOGS_CREATE, WebhookEvent::BLOGS_UPDATE, WebhookEvent::BLOGS_DELETE => syncBlogs($client), }; http_response_code(200); } catch (WebhookException $e) { http_response_code(401); echo json_encode(['error' => $e->getMessage()]); }
$payload exposes event (WebhookEvent enum), timestamp (DateTimeInterface), and eshopId (int).
Branching only on lifecycle action
$payload = $handler->handleFromGlobals(); if ($payload->event->isDelete()) { invalidateCache($payload->event, $payload->eshopId); } if ($payload->event->isCreate() || $payload->event->isUpdate()) { triggerResync($payload->event, $payload->eshopId); }
Error Handling
use Pobo\Sdk\Exception\ApiException; use Pobo\Sdk\Exception\ValidationException; try { $result = $client->importProducts($products); } catch (ValidationException $e) { print_r($e->errors); } catch (ApiException $e) { echo $e->httpCode, ': ', $e->getMessage(); print_r($e->responseBody); }
| Exception | Thrown when |
|---|---|
ValidationException |
Local pre-flight check fails — empty payload or more than 100 items in a bulk request. The HTTP request is not sent. |
ApiException |
The HTTP request fails or the API returns >= 400. Exposes httpCode and parsed responseBody. |
WebhookException |
The webhook signature is missing/invalid, the body cannot be parsed, or the event name is unknown. |
Per-item validation failures during bulk import (invalid URL, missing language, …) don't raise
ApiException— they appear in$result->errors[]while the rest of the batch is processed.
API Methods
| Method | Description |
|---|---|
importProducts(array $products) |
Bulk import products (max 100) |
importCategories(array $categories) |
Bulk import categories (max 100) |
importBrands(array $brands) |
Bulk import brands (max 100) |
importParameters(array $parameters) |
Bulk import parameters (max 100) |
importBlogs(array $blogs) |
Bulk import blogs (max 100) |
deleteProducts(array $ids) |
Bulk delete products (max 100) |
deleteCategories(array $ids) |
Bulk delete categories (max 100) |
deleteBrands(array $ids) |
Bulk delete brands (max 100) |
deleteBlogs(array $ids) |
Bulk delete blogs (max 100) |
getProducts(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Get products page |
getCategories(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Get categories page |
getBrands(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Get brands page |
getBlogs(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Get blogs page |
iterateProducts(?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Iterate all products |
iterateCategories(?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Iterate all categories |
iterateBrands(?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Iterate all brands |
iterateBlogs(?lastUpdateFrom, ?isEdited, ?include, ?lang) |
Iterate all blogs |
Limits
| Limit | Value |
|---|---|
| Max items per import request | 100 |
| Max items per delete request | 100 |
| Max items per export page | 100 |
| Product/Category ID length | 255 chars |
| Name length | 250 chars |
| URL length | 255 chars |
| Image URL length | 650 chars |
| Description length | 500,000 chars |
| SEO description length | 500 chars |
Testing
Unit tests
vendor/bin/phpunit --testsuite=Unit
Unit tests are pure (no network) and run on every CI build across PHP 8.1–8.5.
Integration tests
Integration tests hit the real api.pobo.space API and verify that the SDK's wire format is in sync with the server. They require a dedicated test e-shop and a valid REST API token.
POBO_API_TOKEN="your-test-eshop-token" vendor/bin/phpunit --testsuite=Integration
If POBO_API_TOKEN is not set, all integration tests are marked skipped (not failed). The CI workflow runs integration tests on pushes to master, PRs from the same repository (forks are skipped — secrets are not exposed there), and manual dispatch. Each run uses a unique ID prefix and tearDown() removes anything the test created.
License
MIT License