PrestaShop Integration¶
Reference for App\Integrations\PrestaShop\PrestaShopIntegration. Covers the connectivity verification contract, the customer, product, and order bulk-export implementations, inbound consent-webhook handling, the HTTP endpoints they target, and the test coverage for each.
Class¶
Namespace: App\Integrations\PrestaShop\PrestaShopIntegration
Extends: App\Services\AbstractIntegration
Implements: CanExportInBulk, CanHandleWebhooks, CanPullConsent, CanVerifyConnections, HasConnectionConfiguration, HasDeletionLog (use ExportsInBulk, FetchesDeletionLog, HandlesWebhooks, PullsConsent, VerifiesConnections)
PrestaShopIntegration and UserPlatformIntegration are the two registered integrations implementing CanHandleWebhooks and CanPullConsent — see Registered Integrations.
Connectivity Verification¶
PrestaShopIntegration implements performVerification(SystemConnection $connection): VerificationResult, which is called by the sealed VerifiesConnections::verify(). Any uncaught Throwable from performVerification() is caught by verify() and converted into a failed VerificationResult, so callers never handle exceptions from the verification path.
What it does¶
- Reads
api_keyfrom the connection's encryptedcredentialsarray. GETs{base_url}/api/shops?output_format=JSONusing HTTP Basic Auth — the API key as the username, an empty password (the PrestaShop legacy Webservice API convention).- TLS certificate verification is disabled when running locally (
app()->isLocal()), to allow self-signed certificates in local PrestaShop containers. $response->throw()raises on any non-2xx status, whichVerifiesConnections::verify()catches and converts to a failedVerificationResult.- If this is a finalizing plugin-initiated connection (see Widget token push below), pushes a widget token to the module.
- On success, uses the connection's
base_urlas the external user identifier — not the shop's internal id (shops.0.id). Nearly all PrestaShop installs are single-shop and reportshops.0.id === 1, which would collide with the(tenant_id, system_id, external_user_id)unique index across unrelated shops; the base URL is unique per shop and matches the patternWooCommerceIntegrationuses.
Endpoint¶
Authenticated via HTTP Basic Auth: credentials.api_key as username, empty password.
Outcomes¶
| Condition | VerificationResult returned |
|---|---|
200 |
VerificationResult::success($baseUrl) — successful = true, externalUserId is the connection's base URL |
Any non-2xx status (e.g. 401 for an invalid API key) |
$response->throw() raises an Illuminate\Http\Client\RequestException, caught by VerifiesConnections::verify() and converted to VerificationResult::failure($e->getMessage()) |
| Network error (e.g. connection timeout) | Illuminate\Http\Client\ConnectionException, caught the same way |
VerificationResult shape¶
Namespace: App\Data\VerificationResult
| Property | Type | Description |
|---|---|---|
successful |
bool |
true on success, false on any failure |
externalUserId |
?string |
The connection's base URL on success; null on failure |
message |
?string |
Human-readable failure reason; null on success |
Widget token push¶
A private pushWidgetTokenIfFinalizingPluginInitiated(SystemConnection $connection): void helper, called from performVerification() right after the API-key check succeeds, pushes a widget access token to the PrestaShop module — mirroring the equivalent push WooCommerceIntegration performs for its own plugin.
When it fires. Only when the connection is persisted ($connection->exists) and has no tenant yet (tenant_id === null). That combination identifies one specific moment in the plugin-initiated handshake: the already-connected caller's POST /connect finalization step, where a pending, tenant-less SystemConnection created earlier by the plugin's own POST /register call is re-verified before being handed a tenant. The helper is a no-op in the two other cases performVerification() can run under:
- Transient connections — the plugin's own
POST /registercall verifies an unsaved, in-memorySystemConnection($connection->existsisfalse). - Tenant-initiated (Flow B) connections — by the time verification runs,
tenant_idis already set to the caller's tenant.
What it does. Issues an access token via $this->connectionTokenIssuerService->issueAccessToken($connection, 'plugin-widget') (see Connection Widget for the plugin-widget token name) and POSTs it as { "widget_token": "..." } to:
module_url credential. A PrestaShop module's front-controller directory (e.g. https://shop.example.com/module/rapidmail) is install-specific — it depends on the module's technical name, which branded builds rename and which is not assumed to be conflux or any other fixed value (CFX-152/CFX-230). The plugin therefore supplies its own front-controller base as a plain-text module_url credential at registration time, alongside api_key; a private moduleUrl(SystemConnection $connection): string helper reads it from credentials.module_url, defaulting to an empty string if missing or non-string. SystemSeeder declares it as a non-secret auth_fields entry (store_url, api_key, module_url, brand_name, in that order) for the prestashop system.
brand_name credential. A second, separately-reported non-secret credential — this build's brand identifier, the same value baked into module_url's /module/{name} segment, exposed as its own field. Not read anywhere in PrestaShopIntegration (module_url already covers URL building); it exists purely so Conflux has one generic "which brand is this store paired to" field with the same key name across integrations — conflux-connector (WooCommerce) reports the same brand_name key for its own branded routes, see WooCommerce Integration reference.
Failure handling. Best-effort in the sense that the response is never inspected and ->throw() is never called on it, so an HTTP error status from the module (e.g. a 404 because module_url points at the wrong path) does not fail verification. A connection-level failure (DNS, timeout, refused connection) is a different story: Http::post() raises a ConnectionException for those regardless of ->throw(), which propagates out of performVerification() and is caught by VerifiesConnections::verify() like any other exception — converted into a failed VerificationResult, even though the shop's own API credentials were valid.
Source locale resolution¶
performVerification() calls the public resolveSourceLocale(SystemConnection $connection): ?string right after resolveSourceTimezone() and before the widget token push, and only for a connection that already has a row (RemembersSourceLocale::shouldResolveSourceLocale() — see WooCommerce Integration — source locale resolution for why a transient one is not asked). Two requests, chained:
GET {base_url}/api/configurations?output_format=JSON&filter[name]=PS_LANG_DEFAULT&display=[value] → the default language's numeric id
GET {base_url}/api/languages/{id}?output_format=JSON → that language's locale field
The second response is read as language.locale (PrestaShop 1.7 and later — an IETF tag such as de-DE) falling back to language.language_code where locale is absent or empty (PrestaShop 1.6, and a 1.7 shop upgraded from it whose locale was never backfilled — PrestaShop renders an empty element as "" or [], never as null, so a fallback keyed on null alone would skip exactly those shops). Either way the result is remembered on system_connections.source_locale by the shared App\Services\Concerns\RemembersSourceLocale trait — the same trait WooCommerceIntegration uses (see WooCommerce Integration — source locale resolution): a value that looks like a locale is stored, anything else leaves the previously stored value standing, and a transient (not yet persisted) connection is never written to even though the resolved value is still returned — which is exactly why verification does not ask such a connection in the first place.
Both requests are authenticated the same way as every other Webservice API call — credentials.api_key as the Basic Auth username, empty password — via the same client() helper, capped at PrestaShopIntegration::LOOKUP_TIMEOUT_SECONDS (10 seconds) each: they run inside a synchronous registration call, and two requests on Laravel's 30-second default would hold one up for a minute against a shop that is slow rather than dead. Either request failing (a non-2xx response) or throwing a ConnectionException returns the connection's already-stored source_locale unchanged; the language id being missing, empty or non-scalar does the same, and the second request is then not sent at all — an empty id would address /api/languages/, the collection endpoint. Nothing here calls ->throw().
Locale vs. timezone: load-bearing vs. fallback¶
Both are shop-published settings resolved during performVerification(), one right after the other, but they are not the same kind of thing:
| Timezone | Locale | |
|---|---|---|
| What it feeds | Every source datetime this integration parses | One field (phone) of one outbound writer, UserPlatformOutboundWriter's E.164 normalisation — and only as the last fallback, after the customer's own billing and shipping addresses |
| When it's resolved | On every bulk-export run (see Source timezone resolution above) | Only at verification |
| What an unresolved value costs | Every instant this shop sends becomes a recorded parse failure — see ADR-0018 | A phone number goes unwritten, silently — a missing region and a missing number look identical downstream |
| Why the difference | Load-bearing: without it, nothing this integration reads can become a UTC instant | A fallback: no sync needs it, only a delivery — and a shop changes its language about as rarely as it changes its address, so verification is cheap enough |
Bulk Export¶
PrestaShopIntegration implements fetchBulkExportBatches(), performProbeBulkExport(), performFetchBulkExportPageRange(), and provideTransformerClass(). Both the sequential and the parallel path dispatch on $request->capability via a match with no default arm — all three SystemCapability cases are handled explicitly in each:
| Capability | Sequential (fetchBulkExportBatches()) |
Parallel page range (performFetchBulkExportPageRange()) |
|---|---|---|
Customers |
private fetchCustomerBatches() |
private fetchCustomerPageRange() |
Products |
private fetchProductBatches() |
private fetchProductPageRange() |
Orders |
private fetchOrderBatches() |
private fetchOrderPageRange() |
The sequential helpers run a full pagination loop internally and are exercised whenever a caller drives requestBulkExport()/streamBulkExport() directly, as the unit tests below do. In production, BulkSyncJob instead calls probeBulkExport() first and fans out parallel FetchBulkPageRangeJobs via fetchBulkExportPageRange() — see Parallel bulk export below.
Parallel bulk export (page-range fan-out)¶
Unlike WooCommerce's REST API, PrestaShop's Webservice API exposes no total-count or total-pages response header. performProbeBulkExport() works around this by listing all record ids for the resource in a single unpaginated request — display=[id], no limit parameter at all — and deriving the page count from the number of ids returned:
If the resource has no records at all, totalPages is 0. Otherwise BulkSyncJob fans out FetchBulkPageRangeJob batches of up to 5 pages each (BulkSyncJob::PAGES_PER_JOB) — the same mechanism WooCommerceIntegration uses, just fed by an id count instead of a response header.
performFetchBulkExportPageRange() dispatches on capability the same way fetchBulkExportBatches() does, delegating to a private page-range counterpart (see table above). Each counterpart loops $startPage to $endPage and issues one limit=<offset>,<count> request per page — the same offset-based pagination the sequential loop uses, just bounded to the assigned range instead of running until a short page.
Per-job lookup prefetch¶
Products and Orders both depend on lookups resolved from other PrestaShop resources (category names, the default currency, order state names, currency codes, country names). In the sequential path these are resolved once for the whole export, before the pagination loop starts. The parallel path has no single "whole export" scope — each FetchBulkPageRangeJob runs independently — so each page-range job resolves its own copy of these lookups once per job, before looping over its assigned pages:
| Capability | Lookups resolved once per job |
|---|---|
| Products | category names (fetchLocalizedNameMap(..., 'categories')) and the default currency (fetchDefaultCurrency()) |
| Orders | order state names, currencies, and country names (all via fetchLocalizedNameMap()/fetchCurrencies()) |
This is a deliberate trade-off for job independence, not a bug: a full parallel export re-fetches these lookup resources once per page-range job rather than once per capability. A large product catalogue split across several jobs issues one /api/categories fetch and one /api/configurations+/api/currencies/{id} round-trip per job, not once overall.
Credentials¶
Verification and all three bulk-export paths authenticate via HTTP Basic Auth using credentials.api_key as the username and an empty password. The extraction is shared by a private apiKey(SystemConnection $connection): string helper.
credentials.module_url is a second, non-secret credential, extracted by the private moduleUrl(SystemConnection $connection): string helper and used by the widget token push above and the deletion log endpoint below — bulk export and the shops check never read it. credentials.brand_name is a third, non-secret credential reported alongside it but not read by PrestaShopIntegration at all — see the widget token push section above for why it exists.
Customers¶
Endpoint¶
The response body is wrapped under a customers key — {"customers": [...]} — not a flat array. The integration reads it via $response->json('customers') ?? []. When the requested offset is beyond the total record count, the real Webservice API returns a bare [] with no customers key at all; the ?? [] fallback covers that case, and the empty result ends the pagination loop.
| Query parameter | Value | Description |
|---|---|---|
output_format |
JSON |
Requests JSON instead of the Webservice API's default XML |
display |
[id,firstname,lastname,email,id_gender,birthday,date_add,date_upd,newsletter] |
Explicit field list, PrestaShopIntegration::CUSTOMER_FIELDS. Without it, PrestaShop's list endpoint returns bare {"id": N} objects with no other fields. newsletter was added for consent mapping (CFX-174) — see Consent mapping below |
limit |
"{$offset},{$perPage}", e.g. 0,50 then 50,50 |
PrestaShop's native single-parameter offset syntax: limit=<offset>,<count> |
Pagination¶
page is not a valid pagination parameter for this resource
An earlier version of this integration used separate page/limit query parameters, following the ticket's original assumption. Live testing against a real PrestaShop sandbox showed the Webservice API silently ignores page for the customers resource — requesting page=1, page=2, … with a fixed limit returns the same first N records every time. That would have caused an infinite loop (or silently duplicated data) in production, since the loop's termination condition depends on eventually receiving a short page.
The integration instead tracks an $offset (starting at 0, incremented by PrestaShopIntegration::PER_PAGE, i.e. 50, after each request) and sends it via PrestaShop's native limit=<offset>,<count> syntax — limit=0,50 for the first page, limit=50,50 for the next, and so on. There is no total-count or total-pages header to read; the loop condition is unchanged: it continues until a page returns fewer than PER_PAGE (50) items (count($items) === $perPage).
The parallel counterpart, fetchCustomerPageRange(), uses the identical limit=<offset>,<count> request per page, but bounded to the job's assigned $startPage..$endPage range instead of looping until a short page — see Parallel bulk export above.
Delta sync via modifiedAfter¶
No server-side delta filter exists for this resource
The ticket assumed a date_upd[gt] query filter would restrict the export server-side. Live testing showed the real Webservice API silently ignores it — no error, no filtering effect. Explicitly requesting filter[date_upd] fares worse: PrestaShop returns an HTTP 400 rejecting date_upd as an unsupported filter field for the customers resource on this store (the error lists the allowed fields, e.g. id, id_default_group, id_lang, ..., active, note, is_guest, id_shop, id_shop_group, reset_password_token, reset_password_validity — neither date_add nor date_upd is among them). There is no server-side way to filter or sort by date_upd on this PrestaShop instance.
The integration filters client-side instead. ExportRequest::$modifiedAfter is an optional CarbonImmutable, so it is already the threshold and nothing in this integration parses it. For every fetched page, a private wasModifiedAfter(array $item, CarbonImmutable $threshold, SourceDateTimes $source): bool helper then discards any record whose date_upd is not strictly after that threshold, before the page is mapped to CustomerData and handed to $onBatch. date_upd itself is read through SourceDateTimes::instantForComparison(), in DATETIME_FORMAT and the resolved shop zone, and a record whose date_upd cannot be placed on the timeline is kept rather than dropped; see Source timezone resolution below. When modifiedAfter is null (a first full sync), no filtering is applied and every fetched record is forwarded. The shop timezone is still resolved, because the values that get mapped need it whether or not anything is filtered.
Because the filtering happens after the full page has already been fetched, a delta sync still retrieves the entire customer list from PrestaShop page by page — there is no bandwidth saving on the outbound HTTP calls. The only effect of modifiedAfter is a reduction in what gets mapped, forwarded downstream, and ultimately persisted. This is not a "real" delta sync from the API's perspective; readers relying on this integration for large PrestaShop stores should budget for a full customer scan on every sync regardless of modifiedAfter.
Source timezone resolution¶
PrestaShop's webservice returns date_add and date_upd in shop-local time with no UTC offset (e.g. 2024-06-01 03:00:00). Every value it sends therefore needs the shop's zone before it can become an instant, and the delta filter needs it too, because modifiedAfter is always a UTC instant. Comparing the two naively in the app timezone (UTC) made delta syncs unreliable: shops ahead of UTC re-exported unchanged records on every run, and shops behind UTC could miss changes entirely.
resolveSourceTimezone(SystemConnection $connection): ?string resolves the shop's configured timezone via the PS_TIMEZONE configuration, using the same lookup pattern fetchDefaultCurrency() uses for PS_CURRENCY_DEFAULT (see Default currency resolution above), and validates the value against PHP's timezone_identifiers_list():
It runs on every run, not only when modifiedAfter is set. The full sync is the run that writes the historical values for the first time, and it used to be the one run that resolved nothing. The cost is one request per resource per run, measured against that run's page count. Unlike the other lookups (see Per-job lookup prefetch above) it is not resolved once per page-range job: the sequential path resolves once per bulk-export call, and the parallel path resolves once per run, in performProbeBulkExport(), after which the page-range jobs it fans out read the stored value instead of each asking again. A fan-out of N jobs therefore costs the same single request the sequential run costs, rather than N of them. Which of the two a fetch method does is stated by the parse context it builds: sourceDateTimesForSequentialRun() resolves the zone, sourceDateTimesForPageRangeJob() reads what the run already stored. Naming the two paths is what keeps a new fetch method from picking silently, and PrestaShopTimezoneResolutionTest pins the pairing on both paths for all three capabilities. performVerification() resolves it as well, so a first sync already finds a value rather than parsing its whole first batch against none — for a connection that has a row, which is the same condition the locale lookup applies.
A resolved value is remembered on the connection in system_connections.source_timezone, so a later run that cannot reach the configuration endpoint still has a zone. The column is not fillable: the integration writes it directly, never by mass assignment — and by key rather than through save(), the same single-column write RemembersSourceLocale makes. A save() here would commit system_id, base_url and credentials, which the registration flows deliberately leave in memory until verification has succeeded; with credentials committed by a failed attempt, the finalizing endpoint refuses every later one while the token is still unconsumed. The lookup itself is capped at LOOKUP_TIMEOUT_SECONDS like the locale's. The write-back is skipped for a connection that does not exist yet — the transient connection the plugin-initiated registration flow verifies before it decides whether to insert a row at all — and because nothing would keep the answer, verification does not call the resolver for such a connection at all. A direct call still returns the resolved value; a run against the persisted row resolves it again regardless.
A failed lookup, a missing value, or a value that is not a valid PHP timezone identifier falls back to whatever is already stored on the connection, not to the app timezone. That old fallback was a fair trade while the zone fed nothing but a comparison filter, but for a stored instant it is the assumption ADR-0018 forbids, because a wrong zone produces a plausible-looking wrong instant. Where nothing is stored either, the zone is unestablished, sourceDateTimes() receives null, and every instant parsed against it is a recorded parse failure rather than a guess.
The modifiedAfter filter assumes nothing either. wasModifiedAfter() reads date_upd through SourceDateTimes::instantForComparison(), which parses in DATETIME_FORMAT and returns null where the zone was never established or the value does not match the format, and the filter then keeps the record instead of dropping it. The two mistakes are not symmetrical: a record wrongly kept is mapped and then reported as unchanged, whereas a record wrongly dropped is invisible, so a shop whose zone cannot be resolved shows up as a shop full of unreadable timestamps in the log rather than as an empty shop. The failure itself is reported once, by the mapper reading the same field of the same record moments later; the filter's entry point records nothing, because it is handed no field, domain or source id to report against.
Datetime fields¶
The table covers every datetime the integration reads, bulk export and deletion log alike, so no field is discarded by accident. DATETIME_FORMAT is 'Y-m-d H:i:s' and DATE_FORMAT is 'Y-m-d', both declared as constants on the integration. See ADR-0018 for the rule this table applies.
| Source field | Classification | Why |
|---|---|---|
date_add |
Parsed | An instant, read through the resolved shop zone and converted to UTC, on all three resources into createdAt |
date_upd |
Parsed | Same, into updatedAt. Also read by the client-side modifiedAfter filter, in the same format and zone but through instantForComparison(), which records no failure of its own and keeps a record whose value it cannot read |
birthday |
Parsed as a calendar date | Customers only, into dateOfBirth. A calendar date means the same day in every zone, so it takes no zone and parses even where the shop's zone could not be established. 0000-00-00 is PrestaShop's sentinel for "no birthday" and reads as absent rather than as a parse failure |
deleted_at |
Read but unused | Deletion log only, not part of the bulk export. performFetchDeletionLog() reads it into DeletionLogRecord::$deletedAt as a raw string, and nothing consumes it: FetchDeletionLogJob soft-deletes by entity_id and stamps deleted_at itself. It reaches no column and no consumer payload, so rule 1 does not apply and it is not presented as an instant. The module formats it with an explicit Z rather than the offset-less shape the Webservice API uses elsewhere, so a future reader must parse it through the declared-format path rather than assume this integration's DATETIME_FORMAT |
| Every datetime the bulk-export mappers do not map | Dropped | PrestaShop maps no customAttributes at all, so there is no pass-through path for a value the bulk-export mappers do not read. Anything not in the tables below is discarded, not carried as a raw string |
The contrast with WooCommerce is worth naming: an unmapped WooCommerce field travels on as a raw string in customAttributes, whereas an unmapped PrestaShop field is simply gone. Adding a datetime here therefore means mapping it, never relying on pass-through.
Error handling¶
Any response for which $response->failed() is true (any non-2xx status, including 401) causes fetchCustomerBatches() to throw IntegrationRequestException with the HTTP status code. There is no token-refresh-retry logic: PrestaShop authenticates with a static api_key rather than an OAuth token, so there is no refresh-token concept to retry against — a 401 is treated like any other HTTP error, and the request is not retried.
Mapping: PrestaShop customer → CustomerData¶
Field mapping lives on PrestaShopTransformer::mapCustomer() (public static), called directly by PrestaShopIntegration from fetchCustomerBatches() — it is not invoked through the TransformerContract::transform() interface.
| PrestaShop field | CustomerData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string; empty string if missing or non-scalar |
| (constant) | sourceSystem |
Always SystemDriver::PrestaShop |
email |
email |
Empty string if missing or non-string |
firstname |
firstName |
null if missing, non-string, or empty |
lastname |
lastName |
null if missing, non-string, or empty |
date_add |
createdAt |
Parsed through the resolved shop zone into a UTC CarbonImmutable; null if missing, non-string or unreadable |
date_upd |
updatedAt |
Same; null if missing, non-string or unreadable |
id_gender |
gender |
Via mapGender(): 1 → Gender::Male, 2 → Gender::Female, any other scalar → Gender::Unknown, missing or non-scalar → null |
birthday |
dateOfBirth |
Parsed as a calendar date at midnight, never converted, so it takes no zone. PrestaShop's null-date sentinel '0000-00-00' and an empty string both read as absent and map to null without recording a parse failure; anything else that does not match 'Y-m-d' is a parse failure and also maps to null |
newsletter |
(a ConsentData of its own) |
Not part of CustomerData. mapCustomerRecords() emits [CustomerData, ConsentData…] — see Consent mapping below |
addressBilling and addressShipping are not mapped — always null. The real PrestaShop Legacy Webservice API exposes addresses as a separate /api/addresses resource, not inline on the customer record; inline address mapping is deferred to a future ticket. tags is not populated either — it defaults to an empty array as defined by CustomerData.
Consent mapping (ConsentData)¶
PrestaShopTransformer::mapConsents() (private), called from mapCustomerRecords(), maps the bulk-export newsletter field into a ConsentData that follows its customer in the emitted records. PrestaShop has a single newsletter flag and no per-channel breakdown, so a customer yields at most one consent, always on the email channel:
ConsentData property |
Value | Notes |
|---|---|---|
sourceId |
{customerSourceId}:email |
Built by ConsentData::sourceIdFor(); parentSourceId is the customer's source id |
subscribed |
$rawNewsletter === '1' |
PrestaShop's Webservice API reports booleans as the strings '0'/'1', not JSON booleans. performPullConsent() (see Consent (inbound webhook + pull) below) reuses this exact mapping, so both paths interpret newsletter identically |
confirmed |
null |
Always unknown. newsletter is a single opt-in flag and optin is partner offers, so neither says the address was confirmed, and confirmed no longer mirrors subscribed. A migration set confirmed to null on the rows that already existed |
channel |
ConsentChannel::Email |
The only channel PrestaShop exposes |
consentUpdatedAt |
The customer's own date_upd |
No field-level timestamp exists for when newsletter itself last changed; date_upd is reused as a proxy, and a two-way conflict between mirrors is settled by it — see the caveat in the Integration Contract reference |
source |
ConsentSource::Integration |
Emits no consent — not an "unsubscribed" one — when newsletter is missing from the response or not a scalar value, or when date_upd is unreadable. A channel the source did not mention is left untouched by ConsentRecorder::write(), so "PrestaShop said nothing" is never persisted as a withdrawal. AbstractTransformer::store() routes the ConsentData to ConsentRecorder::write() (see Internal Consent Recorder), which moves the consent's own change signal only when a value changes.
Products¶
Endpoint¶
The response body is wrapped under a products key — {"products": [...]} — read the same way as customers, via $response->json('products') ?? [].
| Query parameter | Value | Description |
|---|---|---|
output_format |
JSON |
Requests JSON instead of the Webservice API's default XML |
display |
full |
The full resource representation, not a restricted field list. Unlike customers, an explicit display=[id,name,...] field list omits associations.categories and id_default_image — both are required for mapping and only come back with display=full |
limit |
"{$offset},{$perPage}", e.g. 0,50 then 50,50 |
Same offset-based syntax as customers |
Category name resolution¶
Product category IDs (associations.categories[].id) need to be resolved to localized names, but PrestaShop does not inline category names on the product resource. Rather than issuing a /api/categories/{id} lookup per product (an N+1 pattern — categories are shared across many products), a private fetchCategoryNames(string $baseUrl, string $apiKey): array helper fetches all categories once per bulk-export call, before the product pagination loop starts:
This is paginated the same way as products/customers (offset-based limit, loop until a short page). Each category's name field — like a product's name — is PrestaShop's translatable-field shape (see below) and is resolved via PrestaShopTransformer::mapLocalizedValue(). The result is an array<int, string> lookup keyed by category ID, passed into PrestaShopTransformer::mapProduct() for every product on every page.
Default currency resolution¶
PrestaShopTransformer::mapProduct() takes a fourth parameter, ?string $currency, alongside $data, $categoryNames, and $baseUrl. Like $categoryNames, the currency is resolved once per bulk-export call, before the product pagination loop starts, by a private fetchDefaultCurrency(string $baseUrl, string $apiKey): ?string helper — not per product.
Resolution is a two-step lookup:
GET {base_url}/api/configurations?filter[name]=PS_CURRENCY_DEFAULT → the default currency's numeric id
GET {base_url}/api/currencies/{id} → that currency's iso_code, e.g. "EUR"
Unlike category resolution — where a failure throws IntegrationRequestException and aborts the whole export — a failure at either step of currency resolution is non-fatal: fetchDefaultCurrency() returns null, every product in the export gets currency: null, and the product sync proceeds normally. This is deliberate: currency is enrichment metadata, not core to any acceptance criterion, so a currency-resolution outage should not block product syncing the way a category-resolution outage does.
Pagination¶
Identical strategy to customers: an $offset starting at 0, incremented by PrestaShopIntegration::PER_PAGE (50) after each request, sent via limit=<offset>,<count>. The loop continues until a page returns fewer than PER_PAGE items. The categories fetch (above) uses the same pagination loop independently, before the products loop begins.
The parallel counterpart, fetchProductPageRange(), resolves $categoryNames and $currency once per job (see Per-job lookup prefetch above), then loops only the job's assigned $startPage..$endPage range using the same limit=<offset>,<count> request per page.
Delta sync via modifiedAfter¶
No server-side delta filter exists for this resource either
Live testing against the same PrestaShop test store confirmed the products resource behaves like customers: requesting filter[date_upd]=... against /api/products returns an HTTP 400 with an explicit list of filterable fields that does not include date_upd. There is no server-side way to filter or sort by date_upd on this resource.
The integration filters client-side, reusing the same private wasModifiedAfter(array $item, CarbonImmutable $threshold, SourceDateTimes $source): bool helper used for customers — including the same resolveSourceTimezone() resolution described in Source timezone resolution above — it checks the date_upd field on each fetched product before mapping. As with customers, a delta sync still retrieves the entire product catalogue from PrestaShop page by page; modifiedAfter only reduces what gets mapped and forwarded, not what gets fetched.
Error handling¶
Any response for which $response->failed() is true (any non-2xx status) causes fetchProductBatches() — or fetchCategoryNames(), if the categories request itself fails — to throw IntegrationRequestException with the HTTP status code. A failure while fetching categories aborts before any request is made to /api/products.
Mapping: PrestaShop product → ProductData¶
Field mapping lives on PrestaShopTransformer::mapProduct(array $data, array $categoryNames, string $baseUrl, ?string $currency): ProductData (public static), called directly by PrestaShopIntegration from fetchProductBatches() with the pre-resolved $categoryNames lookup, the connection's $baseUrl, and the pre-resolved $currency (see Default currency resolution above) — it is not invoked through the TransformerContract::transform() interface.
| PrestaShop field | ProductData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string; empty string if missing or non-scalar |
| (constant) | sourceSystem |
Always SystemDriver::PrestaShop |
name |
name |
Via mapLocalizedValue() — see below |
description |
description |
Via mapLocalizedValue() — same per-language array shape as name. Empty string maps to null |
active |
status |
'1' (string) → ProductStatus::Active; anything else, including missing, → ProductStatus::Inactive. ProductStatus::Deleted is never produced — the Webservice API does not return deleted products at all |
date_add |
createdAt |
Parsed through the resolved shop zone into a UTC CarbonImmutable; null if missing, non-string or unreadable |
date_upd |
updatedAt |
Same; null if missing, non-string or unreadable |
associations.categories[].id |
categories |
Each ID is resolved against the $categoryNames lookup map; falls back to the ID cast to a string if not found in the map. Empty array if associations or associations.categories is missing |
reference |
sku |
null if missing or empty |
ean13 |
ean |
null if missing or empty |
id |
url |
Constructed as {baseUrl}/index.php?controller=product&id_product={id} — deliberately not PrestaShop's friendly-URL (link_rewrite) format; see below. null if id is missing or non-scalar |
price |
price |
Decimal string (e.g. "23.900000") converted to integer minor units via (int) round((float) $price * 100), per this project's monetary-value convention. null if missing or non-scalar |
id + id_default_image |
imageUrl |
Built manually as {baseUrl}/api/images/products/{id}/{id_default_image} — PrestaShop does not return a ready-made image URL, only an image ID. null if id_default_image is missing, non-scalar, or <= 0 |
| (pre-resolved, once per export) | currency |
The pre-resolved $currency argument, passed straight through — see Default currency resolution above. null if resolution failed |
manufacturer_name |
brandName |
Direct passthrough — a plain, non-localized string, unlike name/description. null if missing or empty |
originalPrice, targetGroup, and orderCount are not mapped — always null. tags is not populated either — it defaults to an empty array as defined by ProductData. See Fields deliberately excluded below for why.
url: non-rewritten link, not PrestaShop's friendly URL¶
PrestaShop supports "friendly URLs" via the link_rewrite field (e.g. /12-hummingbird-printed-t-shirt.html), but mapUrl() deliberately does not use it. Friendly-URL resolution depends on shop-specific rewrite configuration — whether it's enabled at all, whether categories are included in the path, the active URL-rewrite ruleset — none of which is reliably reproducible from the API response alone. The non-rewritten index.php?controller=product&id_product={id} format always resolves correctly regardless of shop configuration; this was verified against a live PrestaShop test store.
Fields deliberately excluded¶
These fields were investigated against a live PrestaShop test store's Webservice API and deliberately excluded, to save a future reader from re-investigating the same ground:
ProductData field |
Why it's excluded |
|---|---|
tags |
PrestaShop's product resource has no tags association. A full-representation fetch's associations block only contains categories, images, combinations, product_option_values, product_features, and stock_availables — no tags |
originalPrice |
Would require resolving PrestaShop's specific_price discount-rule associations (date ranges, shop/currency scoping) — materially more complex than a field read. Deferred |
orderCount |
Would require aggregating order data, which isn't product data at all — out of scope for a Products-capability export |
sourceCustomId, customAttributes, targetGroup |
No corresponding PrestaShop concept identified |
mapLocalizedValue(): PrestaShop's translatable-field shape¶
PrestaShop returns translatable fields — a product's name, a category's name — as an array of per-language entries, not a plain string: [{"id": 1, "value": "Hummingbird printed t-shirt"}, {"id": 2, "value": "T-shirt imprimé colibri"}, ...], one entry per shop language. PrestaShopTransformer::mapLocalizedValue(mixed $raw): string (public static) picks the entry with the lowest language id — the shop's default language — and returns its value. Used for product names, product descriptions, and category names. Returns an empty string if $raw is not an array or is empty.
Orders¶
Verified against a live PrestaShop sandbox
Field names and shapes for /api/orders, /api/order_states, /api/currencies, /api/countries, and /api/addresses were confirmed by fetching real data from a running PrestaShop test store — the same kind of live verification already done for Customers and Products. One assumption from the initial implementation turned out to be wrong and was corrected before merging: associations.order_rows[] has no total_price_tax_incl field — a per-row total doesn't exist on the resource at all. LineItemData::total is computed client-side as unitPrice * quantity instead of being read from a non-existent field. Everything else (current_state, id_currency, total_paid_tax_incl, total_paid_tax_excl, total_shipping_tax_incl, id_address_invoice, id_address_delivery, the localized-array shape of order_states.name and countries.name, and the flat addresses field shape) matched the initial implementation exactly.
Endpoint¶
The response body is wrapped under an orders key — {"orders": [...]} — read the same way as customers and products, via $response->json('orders') ?? [].
| Query parameter | Value | Description |
|---|---|---|
output_format |
JSON |
Requests JSON instead of the Webservice API's default XML |
display |
full |
The full resource representation — needed for associations.order_rows[] (the order's line items), which is not returned by a restricted display=[...] field list |
limit |
"{$offset},{$perPage}", e.g. 0,50 then 50,50 |
Same offset-based syntax as customers and products |
One-time lookups resolved before the pagination loop¶
Order fields reference several other PrestaShop resources by numeric ID rather than inlining the resolved value. Following the same "resolve once, not per record" pattern used for product category names and the default currency (see Category name resolution above), three private helpers each run once per bulk-export call (or, on the parallel path, once per page-range job — see Per-job lookup prefetch above), before the order pagination loop starts:
| Helper | Endpoint | Builds | Used to resolve |
|---|---|---|---|
fetchOrderStateNames() |
GET {base_url}/api/order_states?display=[id,name] (paginated) |
array<int, string>, order state id → localized name via mapLocalizedValue() |
current_state → status |
fetchCurrencies() |
GET {base_url}/api/currencies?display=[id,iso_code] (paginated) |
array<int, string>, currency id → ISO code |
id_currency → currency |
fetchCountryNames() |
GET {base_url}/api/countries?display=[id,name] (paginated) |
array<int, string>, country id → localized name via mapLocalizedValue() |
Addresses' country field only |
Unlike currency resolution for products (each order carries its own id_currency, so orders can't share a single pre-resolved default the way products do), fetchCurrencies() builds a full id → ISO-code lookup covering every currency the shop has, resolved per order from that lookup.
Addresses are not in this one-time list — see Targeted address loading below.
Targeted address loading¶
Unlike Customers — where address mapping is explicitly deferred because addresses are a separate /api/addresses resource not inlined on the customer record — Orders does resolve real address data, since address resolution is in scope for this capability. It does so without a full scan of /api/addresses: for every fetched page of orders, a private collectAddressIds() helper collects the distinct id_address_invoice and id_address_delivery ids referenced by that page's orders (skipping missing or <= 0 ids), and a private fetchAddressesByIds() helper loads only those addresses:
filter[id] uses PrestaShop's OR filter syntax — a |-separated list of ids inside brackets. The id list is chunked at PrestaShopIntegration::PER_PAGE (50) ids per request via array_chunk(), so a page referencing more than 50 distinct addresses issues multiple /api/addresses requests. If no order on the page references any address, fetchAddressesByIds() is called with an empty id list and sends no request at all.
This replaced an earlier implementation that fetched the shop's entire /api/addresses collection once per export via fetchAddresses(), regardless of how many (if any) of those addresses were actually referenced by the exported orders.
Both the sequential path (fetchOrderBatches()) and the parallel page-range path (fetchOrderPageRange()) funnel through the same private emitOrderBatch() helper, which performs this per-page collect-then-fetch sequence before mapping the page to OrderData. Addresses are therefore fetched once per fetched page, not once per bulk-export call or once per job.
Pagination¶
Identical strategy to customers and products: an $offset starting at 0, incremented by PrestaShopIntegration::PER_PAGE (50) after each request, sent via limit=<offset>,<count>. The loop continues until a page returns fewer than PER_PAGE items. Each of the three one-time lookups above (order states, currencies, countries) uses the same pagination loop independently, before the orders loop begins. The address lookup is not part of this — it runs once per fetched page instead (see Targeted address loading above).
The parallel counterpart, fetchOrderPageRange(), resolves the three one-time lookups once per job, then loops only the job's assigned $startPage..$endPage range, performing the same per-page targeted address lookup for each page in that range.
Delta sync via modifiedAfter¶
No server-side delta filter is assumed for this resource either
Following the same pattern established for customers and products, no server-side date_upd filter is assumed to work reliably for the orders resource. The live verification against the sandbox (see above) did not specifically re-test filter[date_upd] rejection on /api/orders — the assumption carries over from the confirmed Customers/Products behaviour rather than being independently re-confirmed for Orders.
The integration filters client-side, reusing the same private wasModifiedAfter(array $item, CarbonImmutable $threshold, SourceDateTimes $source): bool helper used for customers and products — including the same resolveSourceTimezone() resolution described in Source timezone resolution above — it checks the date_upd field on each fetched order before mapping. As with customers and products, a delta sync still retrieves the entire order list from PrestaShop page by page; modifiedAfter only reduces what gets mapped and forwarded, not what gets fetched.
Error handling¶
Any response for which $response->failed() is true (any non-2xx status) causes fetchOrderBatches() — or any of fetchOrderStateNames(), fetchCurrencies(), fetchCountryNames(), fetchAddressesByIds(), if that lookup's own request fails — to throw IntegrationRequestException with the HTTP status code. None of the order lookups are non-fatal (unlike default-currency resolution for products): a failure in any one of the three one-time lookups aborts before any request is made to /api/orders, and a failure in the per-page fetchAddressesByIds() call aborts the export after that page's /api/orders request has already succeeded, since addresses are resolved after the orders page is fetched, not before.
Mapping: PrestaShop order → OrderData¶
Field mapping lives on PrestaShopTransformer::mapOrder(array $data, array $orderStateNames, array $currencies, array $addresses): OrderData (public static), called directly by PrestaShopIntegration from fetchOrderBatches() with the four pre-resolved lookups — it is not invoked through the TransformerContract::transform() interface.
| PrestaShop field | OrderData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string; empty string if missing or non-scalar |
| (constant) | sourceSystem |
Always SystemDriver::PrestaShop |
reference |
sourceOrderNumber |
null if missing or empty |
current_state |
status |
Via mapOrderState(): resolved against $orderStateNames; falls back to the numeric id cast to a string if not found in the lookup; empty string if current_state is missing or non-scalar |
id_currency |
currency |
Via mapOrderCurrency(): resolved against $currencies; empty string if not found in the lookup, or if id_currency is missing or non-scalar. Note: OrderData::currency is a required non-nullable string, unlike ProductData::currency, which is nullable |
total_paid_tax_incl |
total |
Minor units via mapAmount(); defaults to 0 if missing or non-scalar — OrderData::total is a required non-nullable int |
total_paid_tax_incl − total_paid_tax_excl |
totalTax |
Computed tax amount in minor units via mapOrderTax(); null if either raw field is missing or non-scalar |
total_shipping_tax_incl |
totalShipping |
Minor units via mapNullableAmount(); null if missing or non-scalar |
date_add / date_upd |
createdAt / updatedAt |
Parsed through the resolved shop zone into UTC CarbonImmutables; null if missing, non-string or unreadable |
id_customer |
customerSourceId |
Cast to string; null if missing or non-scalar |
id_address_invoice / id_address_delivery |
addressBilling / addressShipping |
Via mapOrderAddress(): resolved against $addresses; null if the id is missing, non-scalar, <= 0, or not found in the lookup |
associations.order_rows[] |
lineItems |
Via mapLineItems() / mapLineItem() — see Line item mapping below |
customerId (the Conflux-internal customer UUID) is never set — always null; only customerSourceId (the PrestaShop id) is populated. It is resolved later during persistence, the same way products resolve line items' productId — this is existing AbstractTransformer behaviour, not new for this capability. tags and customAttributes default to empty, same as the other entity DTOs.
Fields deliberately excluded¶
OrderData field |
Why it's excluded |
|---|---|
datePaid |
Confirmed absent from the orders resource via its ?schema=blank field list on the sandbox — no "date paid" field exists (invoice_date tracks invoicing, not payment, and is "0000-00-00 00:00:00" on unpaid orders). Always null |
Line item mapping¶
PrestaShopTransformer::mapLineItem(array $row): LineItemData maps each entry of associations.order_rows[]:
| PrestaShop field | LineItemData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string; empty string if missing |
product_id |
productSourceId |
Cast to string; empty string if missing |
product_name |
name |
Empty string if missing or non-string |
product_quantity |
quantity |
Cast to int; 0 if missing or non-scalar |
unit_price_tax_incl |
unitPrice |
Minor units via mapAmount(); 0 if missing or non-scalar |
| (computed) | total |
unitPrice * quantity — see note below |
product_reference |
sku |
null if missing or empty |
total is computed, not read from a PrestaShop field: associations.order_rows[] has no per-row total field (total_price_tax_incl does not exist on this association — confirmed against a live PrestaShop store, see the verification note above). unit_price_tax_incl * product_quantity reproduces the row's tax-inclusive total.
productId and currency on the line item are never mapped — always null. Product-id resolution happens later, in AbstractTransformer::storeOrder(), by looking up the Product model via productSourceId — this is existing behaviour shared with other integrations, not new for this capability.
Address mapping: PrestaShopTransformer::mapAddress()¶
PrestaShopTransformer::mapAddress(array $data, array $countryNames): AddressData maps a raw PrestaShop address record to AddressData. It's used both by the one-time fetchAddresses() lookup and, transitively, for addressBilling/addressShipping on every mapped order.
| PrestaShop field | AddressData property |
Notes |
|---|---|---|
firstname / lastname |
firstName / lastName |
null if missing or empty |
company |
company |
null if missing or empty |
address1 / address2 |
address1 / address2 |
null if missing or empty |
city |
city |
null if missing or empty |
postcode |
postalCode |
null if missing or empty |
phone |
phone |
null if missing or empty |
vat_number |
vatId |
null if missing or empty |
id_country |
country |
Resolved against $countryNames; null if the id is not found in the lookup or missing/non-scalar |
region and email are deliberately excluded:
AddressData field |
Why it's excluded |
|---|---|
region |
Would require resolving PrestaShop's id_state, a nested resource under countries. Deferred |
email |
PrestaShop's address resource has no email field — it only exists on the customer record |
Consent (inbound webhook + pull)¶
PrestaShopIntegration implements both CanHandleWebhooks/HandlesWebhooks (processWebhook()) and CanPullConsent/PullsConsent (performPullConsent()) for consent. The webhook is only a trigger — it does not carry the consent state itself. On receipt, the integration re-fetches the customer's current state directly from PrestaShop's REST API via pullConsent(), then writes it through ConsentRecorder. This keeps the webhook payload contract minimal (just a customer id) and guarantees the webhook and the bulk-sync path always agree, since both go through the same newsletter mapping.
performPullConsent()¶
protected function performPullConsent(SystemConnection $connection, string $customerSourceId): ?ConsentData
GET {base_url}/api/customers/{customerSourceId} with output_format=JSON and the same display field list as bulk export (CUSTOMER_FIELDS, including newsletter). An explicit field list makes PrestaShop answer in the list endpoint's shape — {"customers": [{...}]} — even on the single-resource endpoint; the singular {"customer": {...}} key is read as a defensive fallback. The response is passed straight into PrestaShopTransformer::mapCustomer() and only the first ConsentData of the emitted records is returned — reusing the exact same mapping as the bulk-sync consent mapping above, so there is a single source of truth for interpreting PrestaShop's newsletter field.
The parse context comes from sourceDateTimesForSingleRecord(), which reads the zone the connection already stored rather than resolving PS_TIMEZONE again: a consent pull reads one customer and is usually driven by a webhook, so resolving would double the request count of every consent change a shop reports. A connection with no stored zone still resolves once.
- HTTP 404 → throws
ConsentTargetNotFoundException(the contract's documented signal that the target system has no matching contact; seeCanPullConsent). It carriesProvesIntegrationReachable, so it does not count towards opening the circuit breaker. - Other failure statuses → throws
IntegrationRequestException. - No
newsletterfield, or no readabledate_upd→ returnsnull(same as the bulk-sync mapping:consentUpdatedAtis required, and a consent without one is omitted rather than written with a guess).
processWebhook()¶
Backs CanHandleWebhooks/HandlesWebhooks (see Webhook Receiver for the endpoint, signature validation, and dispatch pipeline that calls it). Handles a consent-change webhook sent by the separately maintained Positive Group PrestaShop module when a customer's newsletter opt-in changes.
Assumed payload shape
The payload shape below reflects the module's expected implementation at the time this capability was built and is not yet confirmed against the module's final code:
What it does:
- Validates the payload: throws
IntegrationRequestExceptionifcustomer_idis missing or not a scalar. - Looks up the
Customermodel scoped tosystem_connection_id+source_id(matchingcustomer_id). If no matching customer exists, logsDiscarding PrestaShop consent webhook: customer not yet known to Conflux.viaLog::warning()(withsystem_connection_idandcustomer_source_idcontext) and returns — this is not an error: the webhook is simply not retried or escalated. - Calls
$this->pullConsent($connection, $sourceId). If PrestaShop no longer has that customer (ConsentTargetNotFoundException), logsDiscarding PrestaShop consent webhook: customer no longer exists in PrestaShop.(same context) and returns. - If a
ConsentDatacame back, writes it via$this->consentRecorder->write($connection, $consent)(see Internal Consent Recorder), into the receiving connection's own mirror. The write moves the consent'scontent_changed_atwhen a value changed, so a change that arrives only by webhook is now delivered; before, it was stored but moved no signal. IfpullConsent()returnednull, nothing is written.
PrestaShopIntegration injects its own ConsentRecorder in its constructor rather than reading one off AbstractIntegration: it is the rare integration that persists a record outside a bulk import, so the base class stays free of a dependency the others never use.
Outbound path: writing consent back¶
PrestaShopOutboundWriter is the shop's registered outbound writer, registered for SystemDriver::PrestaShop in AppServiceProvider::registerOutboundWriters(). It carries customer_consents and nothing else: a consent rule into a PrestaShop connection delivers through it, onto the shop customer its anchor names (ADR-0042). The direction matrix lists customer_consents for PrestaShop as pull_in, push_in and push_out.
| Verb | Declared by | Behaviour |
|---|---|---|
update() |
CanUpdateRemoteRecords + UpdatesRemoteRecords |
PATCH {base_url}/api/customers/{remoteId} with an XML body carrying id, newsletter (1/0) and, for a subscription only, newsletter_date_add (shop-local) and an IPv4 ip_registration_newsletter where the consent carries them |
create() |
— | Not declared. A consent state is not a reason to create a shop customer |
deactivate() |
— | Not declared. deactivate may only touch lifecycle state, never a consent field (ADR-0016) |
delete() |
— | Not declared. The customer belongs to the shop |
The write is a PATCH naming only the consent fields, because a PUT that omits a field sets it to NULL and a PUT that sends the resource back is refused over its read-only fields. It needs PrestaShop 8.0.1 or newer. On a withdrawal the shop keeps newsletter_date_add and ip_registration_newsletter, which document the original opt-in. PrestaShop stores subscribed or not, so a confirmed and an unconfirmed subscription both become 1.
accepts() comes from the AcceptsConsentChannels trait. It is true only for a customer_consents projection on a channel in supportedConsentChannels() (email) with a known subscribed. Anything else is recorded suppressed by the delivery layer, with no request and no budget token (see accepts()). performUpdate() keeps the same check as a second line, and refuses a projection of any other domain with OutboundWriteException.
| Outcome | Result |
|---|---|
404 |
RemoteRecordGoneException: the shop no longer has the customer |
| Any other failure | OutboundWriteException carrying the status code |
Unit coverage is in tests/Unit/Integrations/PrestaShop/PrestaShopOutboundWriterTest.php: the PATCH body, the opt-in timestamp in the shop's zone and its omission where the zone cannot be established, the IP filter, a withdrawal leaving the opt-in record alone, the no-op and accepts() refusals for an SMS consent or an unknown subscription, a gone customer, and the circuit breaker.
Deletion Log¶
PrestaShopIntegration implements App\Contracts\HasDeletionLog and implements performFetchDeletionLog(SystemConnection $connection, SystemCapability $capability, ?string $since): Collection, the hook of the FetchesDeletionLog trait behind the sealed fetchDeletionLog(). See Integration Contract — Deletion log endpoint contract for the query parameters and response shape shared by every integration, and Deletion Log Sync for why this exists and how FetchDeletionLogJob consumes the returned entries.
The endpoint is provided by the conflux PrestaShop module (CFX-210, a separate plugin repo), which records deletions via actionObjectProductDeleteAfter/actionObjectOrderDeleteAfter/actionObjectCustomerDeleteAfter hooks into its own conflux_deletion_log table, and served through the module's own front controller rather than the Webservice REST API (CFX-230).
Endpoint¶
{module_url} comes from the same credentials.module_url value the widget token push uses, via the same private moduleUrl() helper. Authenticated the same way as the other webservice calls: HTTP Basic Auth using credentials.api_key — the module compares it against its own provisioned webservice key, not against PrestaShop's Webservice API permission system.
Superseded webservice-resource approach (pre-CFX-230)
An earlier implementation hit a hardcoded {base_url}/api/conflux/deletion-log — a Webservice API resource registered via hookAddWebserviceResources() and handled by a WebserviceSpecificManagementConflux class. That path 404'd on every branded module build, since the registered resource name (and the module's whole technical name) is renamed per brand. Both the hook and the class are gone, replaced entirely by the module's deletionlog front controller (controllers/front/deletionlog.php), exposed at the hyphenated public path above via a hookModuleRoutes() alias registered in Conflux::hookModuleRoutes() — the front controller's own technical name stays hyphen-free (PrestaShop derives its PHP class name directly from it), decoupled from the public URL. This mirrors the equivalent fix on the WooCommerce side — see WooCommerce Integration reference.
Response shape¶
deleted_at carries an explicit Z suffix (UTC) — the module formats it that way rather than PrestaShop's usual space-separated, offset-less datetime shape used elsewhere in the Webservice API.
Error handling¶
The module itself only returns a non-2xx status for a malformed entity_type/since (400) or a missing/invalid webservice key (401); neither should occur in practice, since PrestaShopIntegration always sends a valid entity_type, a well-formed ISO-8601 since, and the connection's own api_key.
Retention¶
The module prunes rows older than 90 days, throttled to at most once per day, piggybacked onto this endpoint's own requests (there's no PrestaShop-native scheduler). This is invisible to PrestaShopIntegration — it only affects how far back since = null (a first sync) can see.
PHPStan note¶
base_url is declared as string|null on SystemConnection. Before passing it to rtrim(), the integration casts it explicitly: (string) $connection->base_url. This satisfies PHPStan level 10 (the same pattern WooCommerceIntegration uses, per CFX-80).
Test coverage¶
Tests live in tests/Unit/Integrations/PrestaShop/PrestaShopIntegrationTest.php. All tests use Http::fake() to stub the HTTP layer — no database or network access.
verify tests
| Test | Stubbed response | Assertion |
|---|---|---|
succeeds and resolves the base URL as the external user id |
HTTP 200 |
result->successful is true; result->externalUserId equals the connection's base URL; the outbound request carries an Authorization header |
fails when the api key is rejected |
HTTP 401 |
result->successful is false |
fails when a network error occurs |
ConnectionException thrown by the fake |
result->successful is false |
Source locale resolution tests (tests/Integration/Integrations/SourceLocaleResolutionTest.php, database-backed and shared with WooCommerceIntegration — see WooCommerce Integration — source locale resolution)
| Test | What it covers |
|---|---|
reads the default language and stores its tag |
The PS_LANG_DEFAULT → /api/languages/{id} chain resolves language.locale and stores it on source_locale; both requests are asserted |
falls back to language_code where the shop is too old for locale |
A language resource with no locale field (PrestaShop 1.6) falls back to language_code |
keeps the stored locale when the shop does not answer |
A 500 from either request in the chain leaves the previously stored value in place |
keeps the stored locale when the shop is unreachable |
A ConnectionException — this runs inside verification, so a timeout must still let the shop connect — leaves the previously stored value in place |
refuse a value that is not a locale |
An empty string, a sentence, a number, null, and a non-scalar body all leave the stored value untouched, across both platforms |
report a locale they cannot store yet for a connection with no row |
A transient (unsaved) connection is not written to, but the resolved value is still returned |
write nothing when the locale has not changed |
Resolving the same locale a connection already carries does not touch updated_at |
PrestaShop establishes the locale while verifying |
performVerification() populates source_locale as a side effect of a successful verify |
The one thing no mocked test can establish is whether a real shop's /api/languages/{id} resource actually carries locale or language_code — tests/Live/SourceLocaleResolutionLiveTest.php checks that against a running shop, alongside the same question for WooCommerce. Skipped unless configured and never run in CI; see Run the Live Tests Against a Real Shop.
Widget token push tests (tests/Integration/Integrations/PrestaShop/PrestaShopIntegrationTest.php — this tier because SystemConnection::factory()->create() persists to the database, unlike every other test in this file)
| Test | What it covers |
|---|---|
pushes a widget token when finalizing a plugin-initiated connection |
A persisted, tenant-less connection with credentials.module_url set triggers a POST {module_url}/connectivitycheck carrying a non-empty widget_token, and creates one plugin-widget access token |
does not push a widget token for a transient (not yet persisted) connection |
An unsaved SystemConnection ($connection->exists === false) never triggers a connectivitycheck request |
does not push a widget token for a tenant-initiated (already-owned) connection |
A persisted connection that already has a tenant_id never triggers a connectivitycheck request |
pullConsent tests (tests/Unit/Integrations/PrestaShop/PrestaShopIntegrationTest.php — Http::fake() only, no database)
| Test | What it covers |
|---|---|
fetches and maps the customer's consent state |
GET /api/customers/{id} is called with the newsletter-inclusive display list; the singular customer response key is mapped via PrestaShopTransformer::mapCustomerRecords() |
returns null when the customer has no newsletter field |
No newsletter in the response → null, same as the bulk-sync mapping |
throws ConsentTargetNotFoundException on HTTP 404 |
A 404 raises ConsentTargetNotFoundException, not the generic IntegrationRequestException |
throws IntegrationRequestException on other HTTP errors |
A 500 raises IntegrationRequestException |
handleWebhook (consent) tests (tests/Integration/Integrations/PrestaShop/PrestaShopIntegrationTest.php — this tier because assertions inspect persisted customer_consents rows)
| Test | What it covers |
|---|---|
pulls and writes the consent state for the matching customer |
A valid webhook payload (just customer_id) triggers a GET /api/customers/{id} and persists a CustomerConsent row (channel: email, subscribed/confirmed/consent_updated_at from the pulled state, source: integration) for the customer matched by source_id |
moves the consent's change signal |
The webhook's write sets content_changed_at on the consent mirror, so the change is delivered and not only stored |
discards the webhook and logs a warning when the customer is not yet known |
No Customer matches customer_id on the connection — no CustomerConsent row is created, pullConsent() is never called, and Log::warning() is called with 'Discarding PrestaShop consent webhook: customer not yet known to Conflux.' |
discards the webhook and logs a warning when PrestaShop no longer has the customer |
pullConsent() raises ConsentTargetNotFoundException (stubbed HTTP 404) — no CustomerConsent row is created, and Log::warning() is called with 'Discarding PrestaShop consent webhook: customer no longer exists in PrestaShop.' |
throws on a payload missing customer_id |
Raises IntegrationRequestException |
only matches the customer on the same connection |
A Customer with the same source_id but a different system_connection_id is not matched — the webhook is discarded and logged, not misapplied to the wrong shop's customer |
fetchBulkExport (Customers) tests (exercised via requestBulkExport(), which drives fetchBulkExportBatches() → fetchCustomerBatches())
| Test | What it covers |
|---|---|
maps PrestaShop customer fields to CustomerData |
Full field mapping including gender, date of birth and null billing/shipping addresses |
fetches all pages when paginating using offset-based limit |
Pagination continues until a page returns fewer than limit items; asserts the limit query param is sent as 0,50 then 50,50 |
filters out records not modified after modifiedAfter, client-side |
Records with date_upd not strictly after modifiedAfter are discarded client-side before mapping |
returns all records when modifiedAfter is not set |
No client-side filtering is applied on a first full sync — all fetched records are forwarded |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
throws IntegrationRequestException on HTTP 401 without retrying |
A 401 response raises IntegrationRequestException with no retry — Http::assertSentCount(1) |
fetchBulkExport (Products) tests (exercised via requestBulkExport(), which drives fetchBulkExportBatches() → fetchProductBatches())
| Test | What it covers |
|---|---|
resolves category names and default currency once, and maps PrestaShop product fields to ProductData |
Full field mapping — including categories resolved via the one-time /api/categories fetch, description, url, brandName, the resolved currency, and the constructed image URL |
fetches all product pages when paginating using offset-based limit |
Pagination continues until a page returns fewer than limit items; asserts the limit query param on /api/products is sent as 0,50 then 50,50 |
filters out products not modified after modifiedAfter, client-side |
Products with date_upd not strictly after modifiedAfter are discarded client-side before mapping |
throws IntegrationRequestException on HTTP error from the products endpoint |
A 5xx response from /api/products raises IntegrationRequestException |
throws IntegrationRequestException on HTTP error from the categories endpoint |
A 5xx response from /api/categories raises IntegrationRequestException before any request reaches /api/products |
currency resolution failure is non-fatal tests (nested describe() within fetchBulkExport (Products))
| Test | What it covers |
|---|---|
maps a null currency when the configurations endpoint fails |
A 5xx from /api/configurations results in currency: null on the mapped product — the product sync still completes |
maps a null currency when the currencies endpoint fails |
A 5xx from /api/currencies/{id} (after a successful /api/configurations lookup) results in currency: null — the product sync still completes |
fetchBulkExport (Orders) tests (exercised via requestBulkExport(), which drives fetchBulkExportBatches() → fetchOrderBatches())
| Test | What it covers |
|---|---|
resolves lookups once, fetches referenced addresses, and maps PrestaShop order fields to OrderData |
Full field mapping — including the once-per-export /api/order_states, /api/currencies, /api/countries resolution, the per-page /api/addresses lookup restricted to the ids referenced by the fetched page, resolved status/currency, computed totalTax, resolved billing/shipping addresses, and mapped line items; asserts Http::assertSentCount(5) |
fetches all order pages when paginating using offset-based limit |
Pagination continues until a page returns fewer than limit items; asserts the limit query param on /api/orders is sent as 0,50 then 50,50 |
filters out orders not modified after modifiedAfter, client-side |
Orders with date_upd not strictly after modifiedAfter are discarded client-side before mapping |
throws IntegrationRequestException on HTTP error from the order_states endpoint |
A 5xx response from /api/order_states raises IntegrationRequestException before any request reaches /api/currencies |
throws IntegrationRequestException on HTTP error from the currencies endpoint |
A 5xx response from /api/currencies raises IntegrationRequestException before any request reaches /api/countries |
throws IntegrationRequestException on HTTP error from the countries endpoint |
A 5xx response from /api/countries raises IntegrationRequestException before any request reaches /api/addresses |
throws IntegrationRequestException on HTTP error from the orders endpoint |
A 5xx response from /api/orders raises IntegrationRequestException |
throws IntegrationRequestException on HTTP error from the addresses endpoint |
A 5xx response from the per-page /api/addresses lookup raises IntegrationRequestException |
maps a null billing address and skips the addresses request when no order references an address |
When no fetched order has a usable id_address_invoice/id_address_delivery, addressBilling maps to null and no /api/addresses request is sent at all |
probeBulkExport tests
| Test | What it covers |
|---|---|
derives the total page count from the id listing |
120 customer ids returned from a single unpaginated GET /api/customers?display=[id] request yield totalPages: 3, perPage: 50 |
probes the endpoint matching the capability |
The same id-listing probe mechanism targets /api/customers, /api/products, or /api/orders depending on ExportRequest::$capability |
returns zero pages when the shop has no records |
An empty id listing yields totalPages: 0 |
throws IntegrationRequestException on HTTP error |
A 5xx response from the id-listing request raises IntegrationRequestException |
fetchBulkExportPageRange tests
| Test | What it covers |
|---|---|
fetches only the requested pages for customers |
Requesting pages 2–3 sends only limit=50,50 and limit=100,50 — limit=0,50 (page 1) is never requested; Http::assertSentCount(2) |
does not emit a batch for empty pages |
An empty page in the requested range produces zero batches |
applies the modifiedAfter filter client-side |
Client-side modifiedAfter filtering applies identically to the page-range path |
resolves product lookups once per page range |
Category names and the default currency are each resolved exactly once per fetchProductPageRange() call, not once per page within the assigned range; Http::assertSentCount(4) for a 2-page range |
fetches only the addresses referenced by the fetched order pages |
Only the id_address_invoice/id_address_delivery ids present on the requested order pages are loaded, via the chunked filter[id]=[...] lookup; Http::assertSentCount(5) |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
shop timezone handling tests
| Test | What it covers |
|---|---|
interprets date_upd in the shop timezone when filtering |
A customer updated at 03:00 Europe/Berlin (01:00 UTC) is kept against a 2024-06-01T00:00:00Z threshold, while one updated at 01:00 Europe/Berlin (23:00 UTC the previous day) is discarded; asserts the PS_TIMEZONE configuration lookup is sent |
fetches the shop timezone for full exports too, not only when modifiedAfter is set |
/api/configurations (PS_TIMEZONE) is requested even with no modifiedAfter, which is the run that writes the historical values |
a zone that could not be established keeps every record in the delta batch |
A 5xx from the PS_TIMEZONE lookup does not abort the export. With nothing stored on the connection either, the filter can place no date_upd on the timeline, so both records are forwarded and their instants become recorded parse failures |
an invalid PS_TIMEZONE value keeps the record in the delta batch |
A PS_TIMEZONE value that isn't a valid PHP timezone identifier (e.g. Not/AZone) leaves the zone unestablished, so the comparison keeps the record just as it does when the lookup fails outright; whether the invalid value is refused rather than persisted is shown separately, by the database-backed an unknown identifier is refused rather than stored |
delta filter datetimes tests
| Test | What it covers |
|---|---|
the shop zone decides which side of the threshold a record falls on |
A customer updated at 21:00 America/New_York (2024-06-01 01:00 UTC) is kept against a 2024-06-01T00:00:00Z threshold, while one updated at 19:00 (23:00 UTC the previous day) is discarded. Both strings read as the day before the threshold, so the test fails if the zone conversion is dropped |
a date_upd that does not match the declared format keeps the record |
01.06.2020 03:00:00 cannot be read as Y-m-d H:i:s, so the record stays in the batch with updatedAt null, where a heuristic parse would have read June 2020 and dropped it |
the filter records no parse failure of its own |
An unreadable date_upd produces exactly one aggregated warning, with count 1 and the record's source id, rather than one from the filter and one from the mapper |
source timezone resolution tests (tests/Integration/Integrations/PrestaShop/PrestaShopTimezoneResolutionTest.php, database-backed because the resolved zone is persisted)
| Test | What it covers |
|---|---|
a full sync resolves the zone, although it has no modifiedAfter |
The lookup is unconditional |
the resolved zone is persisted on the connection |
system_connections.source_timezone carries the value after the run |
a failed lookup falls back to the stored value rather than to the app timezone |
The stored zone survives an unreachable configuration endpoint |
a failed lookup with nothing stored resolves to null, so no zone is assumed |
No zone is invented where none is known |
an unknown identifier is refused rather than stored |
An invalid PS_TIMEZONE never reaches the column |
verification already resolves it, so the first sync finds a value |
performVerification() populates the column ahead of the first export |
fetchDeletionLog tests
| Test | What it covers |
|---|---|
maps deletion log entries and passes entity_type and since params |
A single deletion log entry maps to DeletionLogRecord correctly; asserts both entity_type and since query params are sent |
builds the URL from the branded module_url credential |
A connection with credentials.module_url pointing at /module/rapidmail hits {module_url}/deletion-log at that branded path, not the default conflux path |
omits since param when since is null |
No since query param is sent when $since is null |
sends the customer entity type |
SystemCapability::Customers resolves to entity_type=customer |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
source zone on the sequential path / source zone on the fan-out path tests (same file), the mirror-image pair that pins which parse context each of the six fetch methods builds
| Test | What it covers |
|---|---|
every capability resolves it, although the connection already carries one |
Each of the three fetch*Batches() methods asks for PS_TIMEZONE and refreshes the column, even against a connection that already carries a zone. The stored zone is what makes this discriminate: without one, the page-range factory falls back to resolving and a wrong pick would still look right |
every capability reads the stored zone rather than requesting it |
Each of the three fetch*PageRange() methods parses against the stored zone and sends no PS_TIMEZONE request. The assertion is narrowed to PS_TIMEZONE rather than to the endpoint, because a product page range legitimately asks the same endpoint for PS_CURRENCY_DEFAULT once per job |
a page-range job resolves when the connection carries no zone |
The fallback in storedSourceTimezone(): a job that somehow runs before any refresh still parses against a zone |
the probe refreshes the zone, so a changed PS_TIMEZONE reaches the next run |
performProbeBulkExport() is the once-per-run refresh point that keeps the stored value from going stale for good |
Field mapping for PrestaShopTransformer::mapCustomer() (including gender variants, date-of-birth edge cases, and the consent mapping newsletter: '1'/'0'/missing edge cases), PrestaShopTransformer::mapProduct() (including localized-name and localized-description resolution, status, sku, price, categories, image-URL, url, brandName, and currency edge cases), PrestaShopTransformer::mapOrder() (including status/currency lookup fallbacks, total/totalTax/totalShipping edge cases, address resolution, and line item mapping), and PrestaShopTransformer::mapAddress() (including country-lookup resolution and missing-field defaults) is covered separately in tests/Unit/Integrations/PrestaShop/PrestaShopTransformerTest.php.
Source files¶
| File | Purpose |
|---|---|
app/Integrations/PrestaShop/PrestaShopIntegration.php |
Integration implementation — sequential fetchBulkExportBatches() plus the parallel performProbeBulkExport()/performFetchBulkExportPageRange() overrides for Customers, Products, and Orders, resolveSourceLocale(), the widget token push, performPullConsent(), and processWebhook() for consent |
app/Services/Concerns/RemembersSourceLocale.php |
Shared trait behind resolveSourceLocale() on both PrestaShopIntegration and WooCommerceIntegration — validates a candidate locale and remembers it on the connection |
tests/Integration/Integrations/SourceLocaleResolutionTest.php |
Database-backed tests for resolveSourceLocale() on both platforms and its wiring into performVerification() |
tests/Live/SourceLocaleResolutionLiveTest.php |
Live tests confirming a real shop/store actually answers with a locale at the field each resolver reads |
app/Integrations/PrestaShop/PrestaShopTransformer.php |
Persistence inherited unchanged from AbstractTransformer; adds mapCustomer(), mapProduct(), mapOrder(), mapAddress(), mapLocalizedValue(), mapConsents(), and their private mapping helpers |
app/Integrations/PrestaShop/PrestaShopOutboundWriter.php |
Outbound consent write — performUpdate() patches newsletter behind UpdatesRemoteRecords; accepts() from AcceptsConsentChannels |
app/Services/ConsentRecorder.php |
Store path for CustomerConsent mirror rows — see Internal Consent Recorder. Injected into PrestaShopIntegration's own constructor and used directly by processWebhook() |
tests/Unit/Integrations/PrestaShop/PrestaShopIntegrationTest.php |
Unit tests for verify(), pullConsent(), requestBulkExport() (Customers, Products, and Orders), probeBulkExport(), and fetchBulkExportPageRange() |
tests/Integration/Integrations/PrestaShop/PrestaShopIntegrationTest.php |
Integration tests for the widget token push and the consent webhook — requires the database because assertions inspect persisted plugin-widget access tokens and customer_consents rows |
tests/Unit/Integrations/PrestaShop/PrestaShopTransformerTest.php |
Unit tests for PrestaShopTransformer::mapCustomerRecords() and mapCustomer(), mapProduct(), mapOrder(), and mapAddress() field mapping |
tests/Integration/Integrations/PrestaShop/PrestaShopTimezoneResolutionTest.php |
Integration tests for resolveSourceTimezone(): the unconditional lookup, the persistence, the two refusal paths, and the sequential/page-range parse context pairing |
database/seeders/SystemSeeder.php |
Seeds the prestashop system's auth_fields, including module_url and brand_name |
See also¶
- Integration Contract reference —
AbstractIntegration,VerificationResult,IntegrationContract,CanHandleWebhooks,ConsentRecorder, and all shared DTOs - Integration Contract design — reasoning behind the verification and error-handling design
- Webhook Receiver reference — the inbound webhook endpoint, signature validation, and dispatch pipeline that calls
processWebhook() - Connection Flow — the plugin-initiated and tenant-initiated handshakes the widget token push distinguishes between
- Connection Widget — what the
plugin-widgettoken is used for once the module receives it - Implement a System Integration — step-by-step guide for adding a new integration
- Transformer Contract reference —
AbstractTransformer,TransformerRegistry, and the unified Eloquent models - Transformer Pipeline design — reasoning behind the transformation and storage design
- WooCommerce Integration reference — the other registered integration, including its own customer bulk-export implementation and its deletion log endpoint
- Deletion Log Sync — reasoning behind
fetchDeletionLog() - ADR-0018: the rule every source datetime follows, and why an unestablished zone is a failure rather than a fallback