tmwk/client-prestashop-api
A PHP client for the PrestaShop API that provides a clean, framework-friendly wrapper for authenticating and sending requests to your store's webservice. Manage products, orders, customers, and other resources directly from your Laravel or PHP application with less boilerplate.
The package is a thin HTTP client around the PrestaShop Webservice. Treat it as a Guzzle wrapper with resource helpers, not an ORM — there are no migrations, no Eloquent models, and no local schema.
1. Install and configure
composer require tmwk/client-prestashop-api
php artisan vendor:publish --provider="Tmwk\ClientPrestashopApi\Providers\ServiceProvider"
Check config/prestashop.php (or whatever the published file is named) and set:
PRESTASHOP_URL=https://shop.example.com
PRESTASHOP_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
The URL should be the shop root, not /api — most clients append /api themselves. Verify against the source before debugging 404s.
2. Enable and scope the key on the PrestaShop side
In the PrestaShop back office: Advanced Parameters → Webservice → Add new key. Grant only the resources you need (products, orders, customers, stock_availables). A read-only key for reporting does not need PUT/POST/DELETE rights — don't grant them. This is your first line of defence against a buggy sync job.
3. Where to look first
In rough priority order:
products(), orders(), customers() or a generic resource('products')).4. First use case
Start read-only: pull a single product and confirm you can parse the response before writing any integration code.
use Tmwk\ClientPrestashopApi\Client;
$client = app(Client::class);
$product = $client->products()->get(1);
dump($product); // likely an array or SimpleXMLElement
dump($product->reference ?? null);
If this returns a PrestaShopWebserviceException with 401, the key is wrong; 403 means the key lacks permission for that resource; 404 usually means the URL/config is wrong. Get this working before touching orders — order data is much more complex and will hide configuration problems behind parsing errors.
Wrap the client behind your own interface. You will almost certainly outgrow this package (PrestaShop API quirks, retries, multi-shop, version differences). Define a narrow contract in your app and adapt the package to it:
interface PrestashopGateway
{
public function product(int $id): ?PrestashopProduct;
public function ordersSince(CarbonInterface $since): array;
public function updateStock(int $stockAvailableId, int $qty): void;
}
Bind TmwkClientGateway to that interface in a service provider. When the package stalls or you swap to raw Guzzle, only one file changes.
Use a repository/service layer, not the client directly in controllers. The package calls are chatty and slow; you want a single place where you can add caching, retries, and logging.
Read-modify-write for updates. PrestaShop does not support partial updates. A PUT replaces the resource, and omitted fields are reset to defaults (or the call fails on validation). The pattern is always:
$product = $client->products()->get($id); // full resource XML
$product['price'] = '19.99';
$product['associations'] = $product['associations']; // preserve!
$client->products()->update($id, $product);
Preserve associations and every multi-language field, or you will silently strip categories, images, and translations on the next save.
Queue every write. Never call the API synchronously from a web request. Jobs give you retries, backoff, and a record of what you sent:
final class PushStockToPrestashop implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [10, 60, 300];
public function handle(PrestashopGateway $gateway): void
{
$gateway->updateStock($this->stockAvailableId, $this->quantity);
}
}
Throttle the queue (redis + RateLimited middleware, or $this->release()) — a bulk import of 5,000 products will otherwise hammer the shop and trip timeouts.
Sync inbound data with polling, not webhooks. PrestaShop has no webhooks. Use the date filter and persist a high-water mark:
$since = Cache::get('prestashop.orders.synced_at', now()->subDay());
$orders = $client->orders()->filter([
'date' => '1',
'date_from' => $since->format('Y-m-d H:i:s'),
])->get();
Store ps_order_id on your local model with a unique index so replays are idempotent. Advance the watermark only after the whole batch commits.
Mirror PrestaShop IDs as foreign keys. ps_product_id, ps_order_id, ps_customer_id — never rely on email or reference as a join key. Customers create duplicate accounts; references get reused by ops staff.
Cache reference data aggressively. Categories, manufacturers, tax rules, carriers, and languages change rarely and cost a request each. Cache them for hours or days:
$categories = Cache::remember('ps.categories', 3600, fn () => $client->categories()->all());
Multi-shop setups: create one client instance per shop, each with its own URL and key, and resolve by store code. Do not share a singleton across shops — the id_shop/id_lang context is baked into the endpoint in some versions.
Testing: the package talks to a real instance, so unit tests should mock your PrestashopGateway interface. For the adapter itself, keep a directory of captured XML fixtures (a product, a multi-language product, an order, a 404 body) and assert parsing against them. Add a single integration test against a staging shop, gated behind an env flag.
XML everywhere. Responses are XML wrapped in <prestashop>…</prestashop>. Some versions of the package return arrays, some SimpleXMLElement, some stdClass from a json_decode of a converted document. Read the response class first — this is the single most common source of "why is $product['name'] an array?" confusion. Multi-language fields return an array keyed by language ID:
$name = $product['name'][1] ?? $product['name']['language']['1'] ?? null;
Handle both shapes defensively, or normalise once in your adapter.
Creating resources: strip the id. On POST, remove id and any xlink_href attributes, otherwise PrestaShop rejects the payload. Some resources also need the <associations> block present even when empty. Capture the new ID from the response body, not from a Location header.
Empty elements vanish. <price/> and <price>0</price> are not the same thing to the parser. When setting a field to empty, PrestaShop may drop it entirely and validate as "missing". Use '0' rather than '' for numeric fields, and null-coalesce every read.
PUT requires the full schema. Partial payloads return 400 with a validation message listing the missing fields — read the body, it usually names the exact node. Related trap: a PUT that succeeds still zeroes any field you omitted, so a "small tweak" can wipe descriptions and associations.
Pagination and display. The default list response includes every field and every association — for orders this is enormous. Always pass filters:
$client->orders()->filter([
'display' => 'full', // or '[id,reference,current_state,date_add]' in 1.7+
'limit' => '50,0', // "count,offset"
'sort' => '[date_add_DESC]',
]);
limit is count,offset, not page/count. Loop with offset until you get fewer rows than requested — the API does not return a total count in the same call.
Stock is not on the product. Inventory lives in stock_availables, keyed by id_product + id_product_attribute (the variant). Updating products.quantity does nothing. You must find the stock_available row and PUT its quantity.
Combinations vs products. Anything variant-specific (price impact, reference, stock, image) sits on combinations and is linked from the product's associations.combinations. Syncing a variable product means three coordinated requests: product, combinations, stock_availables. Do them in that order and wrap the whole thing in a job with compensating rollback.
Trailing slashes and HTTP verbs. PrestaShop's API is picky. https://shop/api/products/1 works; https://shop/api/products/1/ may 404. Some versions reject PATCH outright. If a request fails and the payload looks right, check the exact URL in your logs.
Debug the request, not the error. Add a Guzzle middleware or Log::debug($request->getMethod().' '.$request->getUri()) in your adapter to log every call. Half the "it doesn't work" cases are a URL built with the wrong base or a missing id_lang. Log response bodies at debug for non-2xx — they contain the field-level error.
Timeouts and concurrency. The API is single-threaded and can take seconds under catalogue updates. Set an explicit connect timeout (5s) and read timeout (30s), and never run parallel writes against the same resource. Serialise writes for a given product ID if you have concurrent jobs.
Exceptions are coarse. The package likely throws one exception type for everything with the HTTP code embedded. Wrap it: inspect the code, map 401/403 to config/permission alerts, 404 to "deleted upstream", 5xx to retryable. Fail loudly on 4xx — retrying a malformed payload just multiplies the damage.
Extension points worth knowing. The client class is usually constructor-injected with a Guzzle ClientInterface and config array, so you can swap the HTTP client (add retry middleware, tracing) without forking. If not, prefer composing a Guzzle client over the package than patching it — the low adoption means it is unlikely to be maintained for you, and upstream-breaking changes are your problem either way.
Vendor it if you must. With zero dependents and a Bitbucket origin, treat the package as unmaintained-by-default. Pin the version in composer.json, and if you need a fix, fork it into your own VCS rather than waiting. The escape hatch — copying the two or three classes you actually use into app/Services/Prestashop — is often faster than debugging an opaque wrapper.
How can I help you explore Laravel packages today?