From 8bb0f46f0e20a6289112115063c081537848c5e0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 10 Jun 2026 13:48:03 +0100 Subject: [PATCH 01/65] WIP prduct translations --- src/controllers/SettingsController.php | 110 ++++++++++++++- .../DefineInitializeApiContextEvent.php | 24 ++++ src/models/Settings.php | 125 +++++++++++++++++- src/services/Api.php | 84 ++++++++++-- src/translations/en/shopify.php | 6 + 5 files changed, 335 insertions(+), 14 deletions(-) create mode 100644 src/events/DefineInitializeApiContextEvent.php diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 2b162a97..51fd9e4e 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -64,6 +64,16 @@ public function actionIndex(?Settings $settings = null): Response 'warning' => !Plugin::getInstance()->getApi()->getSession() ? Craft::t('shopify', 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.') : null, ]; + $scopesFieldConfig = [ + 'label' => $settings->getAttributeLabel('scopes'), + 'instructions' => Craft::t('shopify', 'API scopes required for your app integration, including additional features and custom scopes.'), + 'id' => 'scopes', + 'name' => 'settings[scopes]', + 'value' => $settings->getScopes(), + 'readonly' => true, + 'tip' => Craft::t('shopify', 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.'), + ]; + $html = Html::beginTag('div', ['id' => 'products', 'class' => 'hidden']) . // Products tab has to go first because the routing table overrides the `settings` key Cp::editableTableFieldHtml([ @@ -156,12 +166,42 @@ public function actionIndex(?Settings $settings = null): Response 'id' => 'contextualPricingCountries', 'name' => 'settings[contextualPricingCountries]', 'value' => $settings->getContextualPricingCountries(false), - 'errors' => $settings->getErrors('hostName'), + 'errors' => $settings->getErrors('contextualPricingCountries'), 'suggestEnvVars' => true, ]) . Html::tag('hr') . + Html::beginTag('div', ['id' => 'scopes-settings']) . + + Cp::fieldHtml( + Cp::renderTemplate('_includes/forms/copytext.twig', $scopesFieldConfig), + $scopesFieldConfig + ) . + + Cp::checkboxSelectFieldHtml([ + 'id' => 'additionalFeatures', + 'label' => $settings->getAttributeLabel('additionalFeatures'), + 'name' => 'settings[additionalFeatures]', + 'options' => $settings->getAdditionalFeaturesOptions(), + 'values' => $settings->getAdditionalFeatures(), + 'showAllOption' => true, + ]) . + + Cp::autosuggestFieldHtml([ + 'label' => $settings->getAttributeLabel('customScopes'), + 'instructions' => Craft::t('shopify', 'A comma separated list of custom scopes to add to the API requests.'), + 'id' => 'customScopes', + 'name' => 'settings[customScopes]', + 'value' => $settings->getCustomScopes(false), + 'errors' => $settings->getErrors('customScopes'), + 'suggestEnvVars' => true, + ]) . + + Html::endTag('div') . + + Html::tag('hr') . + Cp::fieldHtml( Cp::renderTemplate('_includes/forms/copytext.twig', $authUrlFieldConfig), $authUrlFieldConfig @@ -170,6 +210,53 @@ public function actionIndex(?Settings $settings = null): Response Html::endTag('div') ; + $getScopesAction = 'shopify/settings/get-scopes'; + $js = << { + const scopesSettingsContainer = document.getElementById('scopes-settings'); + if (!scopesSettingsContainer) return; + + let debounceTimer; + + const updateScopes = () => { + const additionalFeatures = Array.from( + scopesSettingsContainer.querySelectorAll('input[name="settings[additionalFeatures][]"]:checked') + ).map(cb => cb.value).filter(Boolean); + + const customScopesInput = document.getElementById('customScopes'); + const scopesInput = document.getElementById('scopes'); + if (!scopesInput) return; + + Craft.sendActionRequest('POST', '$getScopesAction', { + data: { + additionalFeatures, + customScopes: customScopesInput?.value ?? '', + }, + }).then(response => { + scopesInput.value = response.data.scopes; + }); + }; + + scopesSettingsContainer.addEventListener('change', (e) => { + if (e.target.name === 'settings[additionalFeatures][]' || e.target.name === 'settings[additionalFeatures]') { + // Defer so Craft's checkbox-select JS can toggle related checkboxes first + setTimeout(updateScopes, 0); + } else if (e.target.id === 'customScopes') { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(updateScopes, 300); + } + }); + + scopesSettingsContainer.addEventListener('input', (e) => { + if (e.target.id === 'customScopes') { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(updateScopes, 300); + } + }); + })(); + JS; + $this->getView()->registerJs($js); + return $this->asCpScreen() ->title(Craft::t('shopify', 'Settings')) ->tabs([ @@ -182,6 +269,27 @@ public function actionIndex(?Settings $settings = null): Response ->contentHtml($html); } + /** + * Returns the combined scopes string for the given additional features and custom scopes. + * + * @return Response + * @since 7.2.0 + */ + public function actionGetScopes(): Response + { + $this->requireAcceptsJson(); + $this->requirePostRequest(); + + $request = Craft::$app->getRequest(); + $settings = new Settings(); + $settings->setAdditionalFeatures((array)$request->getBodyParam('additionalFeatures', [])); + $settings->setCustomScopes($request->getBodyParam('customScopes', '')); + + return $this->asJson([ + 'scopes' => $settings->getScopes(), + ]); + } + /** * Save the settings. * diff --git a/src/events/DefineInitializeApiContextEvent.php b/src/events/DefineInitializeApiContextEvent.php new file mode 100644 index 00000000..ab9fedd7 --- /dev/null +++ b/src/events/DefineInitializeApiContextEvent.php @@ -0,0 +1,24 @@ + + * @since 7.2.0 + */ +class DefineInitializeApiContextEvent extends Event +{ + /** + * @var array Array of the arguments used to initialize the API context (`Context::initialize()`). + */ + public array $config; +} diff --git a/src/models/Settings.php b/src/models/Settings.php index ae892a54..c21861dd 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -10,6 +10,7 @@ use Craft; use craft\base\Model; use craft\helpers\App; +use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; @@ -32,10 +33,14 @@ class Settings extends Model private string $_accessToken = ''; private string $_hostName = ''; + private array $_additionalFeatures = []; + private string $_customScopes = ''; public string $uriFormat = ''; public string $template = ''; private mixed $_productFieldLayout; + public const REQUIRED_SCOPES = ['read_inventory', 'read_product_listings', 'read_products']; + /** * @var string|null Comma separated list of country codes to use for contextual pricing. */ @@ -53,6 +58,8 @@ public function rules(): array return [ [['clientSecret', 'clientId', 'hostName', 'apiVersion'], 'required'], [['apiVersion'], 'in', 'range' => Plugin::getInstance()->getApi()->getSupportedApiVersions()], + [['additionalFeatures'], 'in', 'range' => array_keys($this->getAdditionalFeaturesOptions()), 'allowArray' => true], + [['customScopes'], 'string', 'skipOnEmpty' => true], [['hostName'], function($attribute) { $hostName = $this->$attribute; @@ -66,10 +73,12 @@ public function rules(): array public function attributes() { $names = parent::attributes(); + $names[] = 'additionalFeatures'; $names[] = 'apiVersion'; $names[] = 'clientId'; $names[] = 'clientSecret'; $names[] = 'contextualPricingCountries'; + $names[] = 'customScopes'; $names[] = 'hostName'; $names[] = 'uriFormat'; $names[] = 'template'; @@ -80,10 +89,12 @@ public function attributes() public function fields(): array { return [ + 'additionalFeatures' => fn() => $this->getAdditionalFeatures(), 'apiVersion' => fn() => $this->getApiVersion(false), 'clientId' => fn() => $this->getClientId(false), 'clientSecret' => fn() => $this->getClientSecret(false), 'contextualPricingCountries' => fn() => $this->getContextualPricingCountries(false), + 'customScopes' => fn() => $this->getCustomScopes(false), 'hostName' => fn() => $this->getHostName(false), 'uriFormat' => 'uriFormat', 'template' => 'template', @@ -96,14 +107,17 @@ public function fields(): array public function attributeLabels(): array { return [ + 'additionalFeatures' => Craft::t('app', 'Additional Features'), + 'apiVersion' => Craft::t('shopify', 'Shopify API Version'), 'authUrl' => Craft::t('shopify', 'Shopify App Auth URL'), 'clientId' => Craft::t('shopify', 'Shopify Client ID'), 'clientSecret' => Craft::t('shopify', 'Shopify Client Secret Key'), - 'apiVersion' => Craft::t('shopify', 'Shopify API Version'), 'contextualPricingCountries' => Craft::t('shopify', 'Context Pricing Countries'), + 'customScopes' => Craft::t('shopify', 'Custom Scopes'), 'hostName' => Craft::t('shopify', 'Shopify Host Name'), - 'uriFormat' => Craft::t('shopify', 'Product URI format'), + 'scopes' => Craft::t('shopify', 'Scopes'), 'template' => Craft::t('shopify', 'Product Template'), + 'uriFormat' => Craft::t('shopify', 'Product URI format'), ]; } @@ -215,7 +229,6 @@ public function getClientSecret(bool $parse = true): string return ($parse ? App::parseEnv($this->_clientSecret) : $this->_clientSecret) ?? ''; } - /** * @param string $hostName * @return void @@ -236,6 +249,112 @@ public function getHostName(bool $parse = true): string return ($parse ? App::parseEnv($this->_hostName) : $this->_hostName) ?? ''; } + /** + * @param array $additionalFeatures + * @return void + * @since 7.2.0 + */ + public function setAdditionalFeatures(array|string $additionalFeatures): void + { + if ($additionalFeatures === '*') { + $additionalFeatures = array_keys($this->getAdditionalFeaturesOptions()); + } + + $this->_additionalFeatures = $additionalFeatures; + } + + /** + * @return array + * @since 7.2.0 + */ + public function getAdditionalFeatures(bool $scopes = false): array + { + if ($scopes && !empty($this->_additionalFeatures)) { + $scopes = []; + foreach ($this->_additionalFeatures as $additionalFeature) { + $adFeat = $this->getAdditionalFeaturesOptions()[$additionalFeature] ?? null; + if ($adFeat) { + $scopes[] = $adFeat['scope']; + } + } + + return $scopes; + } + + return $this->_additionalFeatures; + } + + /** + * @return array + * @since 7.2.0 + */ + public function getAdditionalFeaturesOptions(): array + { + return [ + 'productTranslations' => [ + 'label' => Craft::t('shopify', 'Product Translations'), + 'value' => 'productTranslations', + 'scope' => 'read_locales', + ] + ]; + } + + + /** + * @param string $additionalScopes + * @return void + * @since 7.2.0 + */ + public function setCustomScopes(string $additionalScopes): void + { + // Preserve env var references as-is; normalize plain-text values + if (!str_starts_with($additionalScopes, '$')) { + $additionalScopes = implode(',', array_filter(array_map( + fn($s) => preg_match('/^[a-z0-9_]+$/', $normalized = strtolower(trim($s))) ? $normalized : '', + explode(',', $additionalScopes) + ))); + } + + $this->_customScopes = $additionalScopes; + } + + /** + * @param bool $parse + * @return string + * @since 7.2.0 + */ + public function getCustomScopes(bool $parse = true): string + { + return ($parse ? App::parseEnv($this->_customScopes) : $this->_customScopes) ?? ''; + } + + /** + * @param bool $asArray + * @return array|string + * @since 7.2.0 + */ + public function getScopes(bool $asArray = false): array|string + { + $scopes = array_merge(self::REQUIRED_SCOPES, $this->getAdditionalFeatures(true)); + + $customScopes = $this->getCustomScopes(); + if ($customScopes) { + $scopes = array_merge($scopes, array_filter(array_map( + fn($s) => preg_match('/^[a-z0-9_]+$/', $normalized = strtolower(trim($s))) ? $normalized : '', + explode(',', $customScopes) + ))); + } + + $scopes = array_unique($scopes); + asort($scopes); + + if ($asArray) { + return $scopes; + } + + return implode(',', $scopes); + } + /** * @param string $accessToken * @return void diff --git a/src/services/Api.php b/src/services/Api.php index 89f4b415..6ed32992 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -15,6 +15,7 @@ use craft\log\MonologTarget; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; +use craft\shopify\events\DefineInitializeApiContextEvent; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; @@ -81,6 +82,18 @@ class Api extends Component */ public const EVENT_DEFINE_GQL_QUERY_ARGUMENTS = 'defineGqlQueryArguments'; + /** + * @event DefineInitializeApiContextEvent Trigged before initializing the Shopify API context, which is required for authentication and making API calls. + * @since 7.2.0 + */ + public const EVENT_DEFINE_INITIALIZE_API_CONTEXT = 'defineInitializeApiContext'; + + /** + * @event Event Triggered after the Shopify API context has been initialized, which is required for authentication and making API calls. + * @since 7.2.0 + */ + public const EVENT_AFTER_INITIALIZE_API_CONTEXT = 'afterInitializeApiContext'; + /** * @var Session|null */ @@ -149,6 +162,20 @@ public function getShopGql(): Query return $this->createQuery('shop', $fields); } + /** + * @return Query + * @since 7.2.0 + */ + public function getShopLocalesGql(): Query + { + return $this->createQuery('shopLocales', [ + 'locale', + 'primary', + ], function(QueryBuilder $builder) { + $builder->setArgument('published', true); + }); + } + /** * @param bool $update * @return array|null @@ -203,6 +230,7 @@ public function getShop(bool $update = false): ?array */ public function getProductGql(?string $id = null): Query { + // Create contextual pricing fields (if required) $contextualPricingCountries = Plugin::getInstance()->getSettings()->getContextualPricingCountries(); $contextualPricing = []; @@ -231,6 +259,27 @@ public function getProductGql(?string $id = null): Query } } + // Create translations fields (if required) + $translations = []; + try { + $locales = $this->query($this->getShopLocalesGql()); + + if (empty($locales)) { + throw new \Exception('Shop locales data not found in the response.'); + } + + foreach ($locales as $locale) { + if ($locale['primary']) { + continue; + } + + $localeKey = sprintf('translations_%1$s: translations(locale:"%1$s")', $locale['locale']); + $translations[$localeKey] = ['key', 'value']; + } + } catch (\Exception $e) { + Craft::error($e->getMessage(), __METHOD__); + } + $fields = [ 'edges' => [ 'node' => [ @@ -345,6 +394,8 @@ public function getProductGql(?string $id = null): Query ], ], 'vendor', + // Add translations to the products query + ...$translations, ], ], ]; @@ -596,19 +647,28 @@ public function initializeContext(): void /** @var MonologTarget $webLogTarget */ $webLogTarget = Craft::$app->getLog()->targets['web']; - Context::initialize( - apiKey: $pluginSettings->getClientId(), - apiSecretKey: $pluginSettings->getClientSecret(), - scopes: ['write_products', 'read_products', 'read_inventory'], + $contextConfig = [ + 'apiKey' => $pluginSettings->getClientId(), + 'apiSecretKey' => $pluginSettings->getClientSecret(), + 'scopes' => $pluginSettings->getScopes(true), // This `hostName` is different from the `shop` value used when creating a Session! // Shopify wants a name for the host/environment that is *initiating* the API connection. // Internally, they appear to use this for starting OAuth flows and creating webhooks (but we handle the latter, manually). - hostName: !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost', - sessionStorage: new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'), - apiVersion: $pluginSettings->getApiVersion(), - isEmbeddedApp: false, - logger: $webLogTarget->getLogger(), - ); + 'hostName' => !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost', + 'sessionStorage' => new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'), + 'apiVersion' => $pluginSettings->getApiVersion(), + 'isEmbeddedApp' => false, + 'logger' => $webLogTarget->getLogger(), + ]; + + if ($this->hasEventHandlers(self::EVENT_DEFINE_INITIALIZE_API_CONTEXT)) { + $event = new DefineInitializeApiContextEvent(['config' => $contextConfig]); + + $this->trigger(self::EVENT_DEFINE_INITIALIZE_API_CONTEXT, $event); + $contextConfig = $event->config; + } + + Context::initialize(...$contextConfig); Context::$HTTP_CLIENT_FACTORY = new class() extends HttpClientFactory { public function client(): ClientInterface @@ -617,6 +677,10 @@ public function client(): ClientInterface return new Client(['headers' => ['X-Shopify-Api-Features' => 'include-presentment-prices']]); } }; + + if ($this->hasEventHandlers(self::EVENT_AFTER_INITIALIZE_API_CONTEXT)) { + $this->trigger(self::EVENT_AFTER_INITIALIZE_API_CONTEXT); + } } /** diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index 78fcda77..3fd3b1e5 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -14,8 +14,12 @@ * @since 0.0.1 */ return [ + 'A comma separated list of additional scopes to add to the API requests.' => 'A comma separated list of additional scopes to add to the API requests.', + 'A comma separated list of country codes used to return contextual pricing.' => 'A comma separated list of country codes used to return contextual pricing.', + 'API scopes required for your app integration, including additional features and custom scopes.' => 'API scopes required for your app integration, including additional features and custom scopes.', 'API Connection' => 'API Connection', 'Add a product' => 'Add a product', + 'Additional Features' => 'Additional Features', 'All products' => 'All products', 'Archived in Shopify' => 'Archived in Shopify', 'Are you sure you want to run a complete sync of all products?' => 'Are you sure you want to run a complete sync of all products?', @@ -23,6 +27,7 @@ 'Are you sure you want to delete this webhook?' => 'Are you sure you want to delete this webhook?', 'Channel' => 'Channel', 'Completed' => 'Completed', + 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.' => 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.', 'Couldn’t save settings.' => 'Couldn’t save settings.', 'Create' => 'Create', 'Created' => 'Created', @@ -60,6 +65,7 @@ 'Published Scope' => 'Published Scope', 'Published' => 'Published', 'Queued' => 'Queued', + 'Scopes' => 'Scopes', 'Settings saved.' => 'Settings saved.', 'Settings' => 'Settings', 'Shopify Client ID' => 'Shopify Client ID', From ad2749d29210404f3163a801d7c2683086eddd33 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 10 Jun 2026 14:15:46 +0100 Subject: [PATCH 02/65] tidy translations --- src/translations/en/shopify.php | 64 ++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index 3fd3b1e5..bddaa74f 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -14,102 +14,118 @@ * @since 0.0.1 */ return [ - 'A comma separated list of additional scopes to add to the API requests.' => 'A comma separated list of additional scopes to add to the API requests.', 'A comma separated list of country codes used to return contextual pricing.' => 'A comma separated list of country codes used to return contextual pricing.', + 'A comma separated list of custom scopes to add to the API requests.' => 'A comma separated list of custom scopes to add to the API requests.', 'API scopes required for your app integration, including additional features and custom scopes.' => 'API scopes required for your app integration, including additional features and custom scopes.', 'API Connection' => 'API Connection', 'Add a product' => 'Add a product', - 'Additional Features' => 'Additional Features', 'All products' => 'All products', 'Archived in Shopify' => 'Archived in Shopify', 'Are you sure you want to run a complete sync of all products?' => 'Are you sure you want to run a complete sync of all products?', - 'Are you sure you want to delete this sync?' => 'Are you sure you want to delete this sync?', - 'Are you sure you want to delete this webhook?' => 'Are you sure you want to delete this webhook?', - 'Channel' => 'Channel', + 'Are you sure you want to delete the {topic} webhook?' => 'Are you sure you want to delete the {topic} webhook?', + 'Authorization' => 'Authorization', + 'Authorize' => 'Authorize', + 'Authorize App' => 'Authorize App', 'Completed' => 'Completed', + 'Configure the product’s front-end routing settings.' => 'Configure the product’s front-end routing settings.', + 'Context Pricing Countries' => 'Context Pricing Countries', 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.' => 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.', 'Couldn’t save settings.' => 'Couldn’t save settings.', - 'Create' => 'Create', + 'Create all webhooks' => 'Create all webhooks', + 'Create missing webhooks' => 'Create missing webhooks', 'Created' => 'Created', 'Created At' => 'Created At', - 'Delete {topic} webhook?' => 'Delete {topic} webhook?', - 'Description HTML' => 'Description HTML', + 'Created at' => 'Created at', + 'Custom Scopes' => 'Custom Scopes', + 'Delete {topic} webhook' => 'Delete {topic} webhook', 'Draft in Shopify' => 'Draft in Shopify', 'Edit variant {title} on Shopify' => 'Edit variant {title} on Shopify', + 'Error authorizing app' => 'Error authorizing app', 'Failed to create products sync' => 'Failed to create products sync', 'Failed to delete sync' => 'Failed to delete sync', - 'General' => 'General', 'Handle' => 'Handle', 'Has variants' => 'Has variants', + 'Images' => 'Images', + 'Invalid or missing HMAC. Please try re-installing the app.' => 'Invalid or missing HMAC. Please try re-installing the app.', 'Key' => 'Key', + 'Leave blank if products don’t have URLs' => 'Leave blank if products don’t have URLs', 'Live' => 'Live', 'Media' => 'Media', 'Meta fields' => 'Meta fields', 'Meta Fields' => 'Meta Fields', 'New Product' => 'New Product', - 'No Shopify session available.' => 'No Shopify session available.', - 'No webhooks exist for this environment' => 'No webhooks exist for this environment', - 'Objects' => 'Objects', + 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.' => 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.', + 'No webhooks exist for this environment.' => 'No webhooks exist for this environment.', + 'Open in Shopify' => 'Open in Shopify', 'Option' => 'Option', 'Options' => 'Options', 'Price' => 'Price', 'Processing' => 'Processing', 'Processing bulk operation data' => 'Processing bulk operation data', 'Product Template' => 'Product Template', + 'Product Translations' => 'Product Translations', 'Product Type' => 'Product Type', + 'Product URI Format' => 'Product URI Format', 'Product URI format' => 'Product URI format', 'Product' => 'Product', 'Products sync created' => 'Products sync created', 'Products' => 'Products', 'Published At' => 'Published At', - 'Published Scope' => 'Published Scope', - 'Published' => 'Published', + 'Published at' => 'Published at', 'Queued' => 'Queued', + 'Routing Settings' => 'Routing Settings', 'Scopes' => 'Scopes', 'Settings saved.' => 'Settings saved.', 'Settings' => 'Settings', 'Shopify Client ID' => 'Shopify Client ID', 'Shopify Client Secret Key' => 'Shopify Client Secret Key', 'Shopify API Version' => 'Shopify API Version', - 'Shopify Access Token' => 'Shopify Access Token', + 'Shopify App Auth URL' => 'Shopify App Auth URL', 'Shopify Edit' => 'Shopify Edit', 'Shopify Host Name' => 'Shopify Host Name', 'Shopify ID' => 'Shopify ID', - 'Shopify Product' => 'Shopify Product', 'Shopify Products' => 'Shopify Products', + 'Shopify product' => 'Shopify product', + 'Shopify products' => 'Shopify products', 'Shopify Status' => 'Shopify Status', - 'Shopify plugin loaded' => 'Shopify plugin loaded', + 'Shopify Sync' => 'Shopify Sync', 'Shopify' => 'Shopify', 'SKU' => 'SKU', 'Status' => 'Status', 'Supported API versions: {versions}' => 'Supported API versions: {versions}', 'Sync all' => 'Sync all', - 'Sync all Products' => 'Sync all Products', - 'Sync could not be deleted' => 'Sync could not be deleted', 'Sync deleted' => 'Sync deleted', 'Tags' => 'Tags', 'Template suffix' => 'Template suffix', + 'The host name must be a valid Shopify store domain.' => 'The host name must be a valid Shopify store domain.', + 'The Shopify store hostname.' => 'The Shopify store hostname.', + 'The Shopify store {shop} needs to be authorized to connect with this plugin.' => 'The Shopify store {shop} needs to be authorized to connect with this plugin.', + 'The URL of your Shopify app in the Dev Dashboard. This is automatically generated from your CP URL.' => 'The URL of your Shopify app in the Dev Dashboard. This is automatically generated from your CP URL.', + 'This environment is not subscribed to all the required webhook topics.' => 'This environment is not subscribed to all the required webhook topics.', + 'This environment is subscribed to all the required webhook topics!' => 'This environment is subscribed to all the required webhook topics!', 'This product has no media.' => 'This product has no media.', 'This product has no meta fields.' => 'This product has no meta fields.', 'This product has no options.' => 'This product has no options.', 'This product has no variants.' => 'This product has no variants.', + 'Topic' => 'Topic', 'Total variants' => 'Total variants', - 'Unpublished' => 'Unpublished', + 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.' => 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.', 'Untitled product' => 'Untitled product', 'Updated At' => 'Updated At', - 'Updating product metafields for “{title}”' => 'Updating product metafields for “{title}”', - 'Updating product variants for “{title}”' => 'Updating product variants for “{title}”', + 'Updated at' => 'Updated at', 'Value' => 'Value', 'Values' => 'Values', 'Variant' => 'Variant', 'Variants' => 'Variants', 'Vendor' => 'Vendor', 'View products' => 'View products', + 'Webhook could not be deleted' => 'Webhook could not be deleted', 'Webhook deleted' => 'Webhook deleted', - 'Webhooks could not be deleted' => 'Webhooks could not be deleted', 'Webhooks could not be registered.' => 'Webhooks could not be registered.', - 'Webhooks for the current environment.' => 'Webhooks for the current environment.', 'Webhooks registered.' => 'Webhooks registered.', 'Webhooks' => 'Webhooks', + 'What product URIs should look like.' => 'What product URIs should look like.', + 'Which template should be loaded when a product’s URL is requested.' => 'Which template should be loaded when a product’s URL is requested.', + 'Your Shopify app has been successfully authorized.' => 'Your Shopify app has been successfully authorized.', '{name} option values: {values}' => '{name} option values: {values}', ]; From ac3eedd10f31a7ad08480688f090732f7f9e25a7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 10 Jun 2026 15:07:55 +0100 Subject: [PATCH 03/65] General tidying --- src/controllers/SettingsController.php | 2 ++ src/models/Settings.php | 4 ++-- src/services/Api.php | 31 +++++++++++++++----------- src/translations/en/shopify.php | 1 + 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 51fd9e4e..5c5180cc 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -234,6 +234,8 @@ public function actionIndex(?Settings $settings = null): Response }, }).then(response => { scopesInput.value = response.data.scopes; + }).catch(() => { + Craft.cp.displayError(Craft.t('shopify', 'Couldn't update scopes.')); }); }; diff --git a/src/models/Settings.php b/src/models/Settings.php index c21861dd..f5b60751 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -267,9 +267,9 @@ public function setAdditionalFeatures(array|string $additionalFeatures): void * @return array * @since 7.2.0 */ - public function getAdditionalFeatures(bool $scopes = false): array + public function getAdditionalFeatures(bool $asScopes = false): array { - if ($scopes && !empty($this->_additionalFeatures)) { + if ($asScopes && !empty($this->_additionalFeatures)) { $scopes = []; foreach ($this->_additionalFeatures as $additionalFeature) { $adFeat = $this->getAdditionalFeaturesOptions()[$additionalFeature] ?? null; diff --git a/src/services/Api.php b/src/services/Api.php index 6ed32992..6a09a1db 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -261,23 +261,28 @@ public function getProductGql(?string $id = null): Query // Create translations fields (if required) $translations = []; - try { - $locales = $this->query($this->getShopLocalesGql()); + if (in_array('productTranslations', Plugin::getInstance()->getSettings()->getAdditionalFeatures())) { + try { + $cacheKey = 'shopify:shopLocales:' . Plugin::getInstance()->getSettings()->getHostName(); + $locales = Craft::$app->getCache()->getOrSet($cacheKey, function() { + return $this->query($this->getShopLocalesGql()); + }, 86400); + + if (empty($locales)) { + throw new \Exception('Shop locales data not found in the response.'); + } - if (empty($locales)) { - throw new \Exception('Shop locales data not found in the response.'); - } + foreach ($locales as $locale) { + if ($locale['primary']) { + continue; + } - foreach ($locales as $locale) { - if ($locale['primary']) { - continue; + $localeKey = sprintf('translations_%1$s: translations(locale:"%1$s")', $locale['locale']); + $translations[$localeKey] = ['key', 'value']; } - - $localeKey = sprintf('translations_%1$s: translations(locale:"%1$s")', $locale['locale']); - $translations[$localeKey] = ['key', 'value']; + } catch (\Exception $e) { + Craft::error($e->getMessage(), __METHOD__); } - } catch (\Exception $e) { - Craft::error($e->getMessage(), __METHOD__); } $fields = [ diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index bddaa74f..37b9dffb 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -31,6 +31,7 @@ 'Context Pricing Countries' => 'Context Pricing Countries', 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.' => 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.', 'Couldn’t save settings.' => 'Couldn’t save settings.', + 'Couldn’t update scopes.' => 'Couldn’t update scopes.', 'Create all webhooks' => 'Create all webhooks', 'Create missing webhooks' => 'Create missing webhooks', 'Created' => 'Created', From e10e92a4fbdc56dfee1d9f1b6c7f31685db036d5 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 10 Jun 2026 15:21:00 +0100 Subject: [PATCH 04/65] Add changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceec3786..604be2a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Release Notes for Shopify +## Unreleased + +- Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215)) +- It’s now possible to view the required API scopes in the plugin settings. +- It’s now possible to extend the API scopes with opt-in additional features and custom scopes. +- It’s now possible to customize the Shopify API context before and after initialization via new events. +- Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. +- Added `craft\shopify\events\DefineInitializeApiContextEvent`. +- Added `craft\shopify\models\Settings::REQUIRED_SCOPES`. +- Added `craft\shopify\models\Settings::getAdditionalFeatures()`. +- Added `craft\shopify\models\Settings::getAdditionalFeaturesOptions()`. +- Added `craft\shopify\models\Settings::getCustomScopes()`. +- Added `craft\shopify\models\Settings::getScopes()`. +- Added `craft\shopify\models\Settings::setAdditionalFeatures()`. +- Added `craft\shopify\models\Settings::setCustomScopes()`. +- Added `craft\shopify\services\Api::EVENT_AFTER_INITIALIZE_API_CONTEXT`. +- Added `craft\shopify\services\Api::EVENT_DEFINE_INITIALIZE_API_CONTEXT`. +- Added `craft\shopify\services\Api::getShopLocalesGql()`. +- Fixed a bug where validation errors for the "Context Pricing Countries" setting weren't displaying correctly. + ## 7.1.2 - 2026-06-03 - Fixed a PHP error that occurred when editing settings in Craft 4. ([#216](https://github.com/craftcms/shopify/issues/216)) From 468c3b954a1f3ddab47d4ce6a8f9b285fcecfdd4 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 10 Jun 2026 16:14:00 +0100 Subject: [PATCH 05/65] WIP Readme update --- CHANGELOG.md | 3 ++ README.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 604be2a9..954b58e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +> [!IMPORTANT] +> If you change the **Additional Features** or **Custom Scopes** settings after the app is already authorized, you must update the scopes in your Shopify app configuration and then re-authorize the app. + - Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215)) - It’s now possible to view the required API scopes in the plugin settings. - It’s now possible to extend the API scopes with opt-in additional features and custom scopes. diff --git a/README.md b/README.md index 6a8456bd..0110592f 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,15 @@ To install an app into a store, one of these statements must describe your accou ```bash SHOPIFY_WEBHOOK_VERSION="2026-01" ``` - - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: + - **Access** → **Scopes**: The following scopes are always required: - `read_inventory` - `read_product_listings` - `read_products` - - Shopify requires these to be in a comma-separated list: - ``` - read_inventory,read_product_listings,read_products - ``` + + If you plan to enable any **Additional Features** or **Custom Scopes** in the plugin settings, those will require additional scopes. Once the plugin is installed and configured, use the read-only **Scopes** field in **Shopify** → **Settings** as the source of truth — it always reflects the full, comma-separated string to paste here. + + > [!WARNING] + > If you later change your **Additional Features** or **Custom Scopes** settings, you must update the scopes in your Shopify app configuration and then re-authorize the app from the Craft control panel. - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. 1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. 1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: @@ -1225,8 +1226,10 @@ The following settings can also be set via a `shopify.php` file in your `config/ | `apiKey` | `string` | — | Shopify API key. | | `apiSecretKey` | `string` | — | Shopify API secret key. | | `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. | -| `accessToken` | `string` | — | Shopify API access token. | -| `contextualPricingCountries` | `string` | — | Comma-separated list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/2026-01/enums/CountryCode) that determine which [contextual prices](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant#field-ProductVariant.fields.contextualPricing) are loaded via the API. | +| `accessToken` | `string` | — | Shopify API access token. | +| `additionalFeatures` | `string[]` | `[]` | Array of additional feature handles to enable (e.g. `['productTranslations']`). Enabling features may add required API scopes — see [Additional Features](#additional-features). | +| `contextualPricingCountries` | `string` | — | Comma-separated list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/2026-01/enums/CountryCode) that determine which [contextual prices](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant#field-ProductVariant.fields.contextualPricing) are loaded via the API. | +| `customScopes` | `string` | — | Comma-separated list of additional API scopes to request beyond the plugin's [required scopes](#create-an-app). | | `hostName` | `string` | — | Your store’s hostname. See the [creating an app](#create-an-app) section for more information. | | `uriFormat` | `string` | — | Product element URI format. | | `template` | `string` | — | Product element template path. | @@ -1235,6 +1238,37 @@ The following settings can also be set via a `shopify.php` file in your `config/ > Setting `apiKey`, `apiSecretKey`, `apiVersion`, `accessToken`, or `hostName` via `shopify.php` will override Project Config values set via the control panel during [app setup](#connect-to-shopify). > You can still reference environment values from the config file with `craft\helpers\App::env()`. +### Additional Features + +Additional features are opt-in capabilities that extend the plugin's default behavior. Enabling a feature may add required API scopes — the **Scopes** field in **Shopify** → **Settings** always reflects the complete, up-to-date list for your configuration. + +> [!WARNING] +> Enabling or disabling additional features changes the required API scopes. After saving the settings, you must update the **Access** → **Scopes** field in your Shopify app configuration and then re-authorize the app from the Craft control panel. + +Features can also be enabled via `shopify.php`: + +```php +return [ + 'additionalFeatures' => ['productTranslations'], +]; +``` + +#### Product Translations + +**Handle:** `productTranslations` | **Required scope:** `read_locales` + +When enabled, the plugin fetches Shopify's published store locales during product sync and includes translation data for each non-primary locale in the product's raw data. This lets you surface translated product content (titles, descriptions, etc.). + +Translation data is stored in `product.getData()`, keyed by locale — for example, `translations_fr` for French. Each entry is an array of `key`/`value` pairs corresponding to Shopify's translatable resource keys. + +```twig +{# Loop over French translations for a product #} +{% set translations = product.getData()['translations_fr'] ?? [] %} +{% for translation in translations %} +

{{ translation.key }}: {{ translation.value }}

+{% endfor %} +``` + ### Emulate Sales Channels Private apps no longer come with a sales channel that allows merchants to selectively expose products to the Craft integration. @@ -1380,6 +1414,48 @@ Event::on( Using this event, after the queries have been built, you have the opportunity to add custom arguments to the main query. For example, you can tailor a query for products using the [ProductConnection arguments](https://shopify.dev/docs/api/admin-graphql/2026-01/queries/products#arguments) (like `query`, `reverse`, or `savedSearchId`). +#### `craft\shopify\services\Api::EVENT_DEFINE_INITIALIZE_API_CONTEXT` + +Emitted before the Shopify API context is initialized. The `craft\shopify\events\DefineInitializeApiContextEvent` object exposes a `$config` array containing the arguments that will be passed to [`Context::initialize()`](https://github.com/Shopify/shopify-api-php/blob/main/docs/getting_started.md), allowing you to customize the context before it is applied. + +The event object has one property: + +- `config`: Array of arguments passed to `Context::initialize()`, including `apiKey`, `apiSecretKey`, `scopes`, `hostName`, `sessionStorage`, `apiVersion`, `isEmbeddedApp`, and `logger`. + +```php +use craft\base\Event; +use craft\shopify\events\DefineInitializeApiContextEvent; +use craft\shopify\services\Api; + +Event::on( + Api::class, + Api::EVENT_DEFINE_INITIALIZE_API_CONTEXT, + function(DefineInitializeApiContextEvent $event) { + // Disable the Shopify API logger: + $event->config['logger'] = null; + } +); +``` + +#### `craft\shopify\services\Api::EVENT_AFTER_INITIALIZE_API_CONTEXT` + +Emitted after the Shopify API context has been fully initialized. Use this event to perform setup that depends on a ready context, such as overriding the HTTP client factory. + +```php +use craft\base\Event; +use craft\shopify\services\Api; +use Shopify\Context; + +Event::on( + Api::class, + Api::EVENT_AFTER_INITIALIZE_API_CONTEXT, + function(Event $event) { + // Replace the HTTP client factory with a custom implementation: + Context::$HTTP_CLIENT_FACTORY = new MyHttpClientFactory(); + } +); +``` + ### GraphQL Playground In addition to the [template helper](#api-service), you can execute queries against the Admin GraphQL API via Craft’s CLI: From be3c939df04832bf0ded7eaee429c880127829ed Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 10 Jun 2026 16:15:19 +0100 Subject: [PATCH 06/65] fix cs --- src/models/Settings.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/models/Settings.php b/src/models/Settings.php index f5b60751..827d3afc 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -10,7 +10,6 @@ use Craft; use craft\base\Model; use craft\helpers\App; -use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; @@ -295,7 +294,7 @@ public function getAdditionalFeaturesOptions(): array 'label' => Craft::t('shopify', 'Product Translations'), 'value' => 'productTranslations', 'scope' => 'read_locales', - ] + ], ]; } From dd90413caa74783a24f67500d54015eb495a3b5c Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 11 Jun 2026 14:13:47 -0700 Subject: [PATCH 07/65] Readme, please! :) --- README.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0110592f..11f36d38 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ To install an app into a store, one of these statements must describe your accou - `read_product_listings` - `read_products` - If you plan to enable any **Additional Features** or **Custom Scopes** in the plugin settings, those will require additional scopes. Once the plugin is installed and configured, use the read-only **Scopes** field in **Shopify** → **Settings** as the source of truth — it always reflects the full, comma-separated string to paste here. + If you plan to enable any **Additional Features** or **Custom Scopes** in the plugin settings, those will require additional scopes. Once the plugin is installed and configured, use the read-only **Scopes** field in **Shopify** → **Settings** as the source of truth: it always reflects the full, comma-separated string to paste here. > [!WARNING] > If you later change your **Additional Features** or **Custom Scopes** settings, you must update the scopes in your Shopify app configuration and then re-authorize the app from the Craft control panel. @@ -1221,18 +1221,18 @@ This section describes advanced ways to customize the plugin’s behavior. The following settings can also be set via a `shopify.php` file in your `config/` directory. -| Setting | Type | Default | Description | -|------------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `apiKey` | `string` | — | Shopify API key. | -| `apiSecretKey` | `string` | — | Shopify API secret key. | -| `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. | +| Setting | Type | Default | Description | +|------------------------------|------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `apiKey` | `string` | — | Shopify API key. | +| `apiSecretKey` | `string` | — | Shopify API secret key. | +| `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. | | `accessToken` | `string` | — | Shopify API access token. | -| `additionalFeatures` | `string[]` | `[]` | Array of additional feature handles to enable (e.g. `['productTranslations']`). Enabling features may add required API scopes — see [Additional Features](#additional-features). | +| `additionalFeatures` | `string[]` | `[]` | Array of additional feature handles to enable (e.g. `['productTranslations']`). Enabling features may add required API scopes; see [Additional Features](#additional-features). | | `contextualPricingCountries` | `string` | — | Comma-separated list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/2026-01/enums/CountryCode) that determine which [contextual prices](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant#field-ProductVariant.fields.contextualPricing) are loaded via the API. | | `customScopes` | `string` | — | Comma-separated list of additional API scopes to request beyond the plugin's [required scopes](#create-an-app). | -| `hostName` | `string` | — | Your store’s hostname. See the [creating an app](#create-an-app) section for more information. | -| `uriFormat` | `string` | — | Product element URI format. | -| `template` | `string` | — | Product element template path. | +| `hostName` | `string` | — | Your store’s hostname. See the [creating an app](#create-an-app) section for more information. | +| `uriFormat` | `string` | — | Product element URI format. | +| `template` | `string` | — | Product element template path. | > [!NOTE] > Setting `apiKey`, `apiSecretKey`, `apiVersion`, `accessToken`, or `hostName` via `shopify.php` will override Project Config values set via the control panel during [app setup](#connect-to-shopify). @@ -1240,12 +1240,12 @@ The following settings can also be set via a `shopify.php` file in your `config/ ### Additional Features -Additional features are opt-in capabilities that extend the plugin's default behavior. Enabling a feature may add required API scopes — the **Scopes** field in **Shopify** → **Settings** always reflects the complete, up-to-date list for your configuration. +Additional features are opt-in capabilities that extend the plugin's default behavior. > [!WARNING] -> Enabling or disabling additional features changes the required API scopes. After saving the settings, you must update the **Access** → **Scopes** field in your Shopify app configuration and then re-authorize the app from the Craft control panel. +> Enabling or disabling additional features may change the required API scopes! After saving the settings, you must update the **Access** → **Scopes** field in your Shopify app configuration and then re-authorize the app from the Craft control panel, _in each environment_. -Features can also be enabled via `shopify.php`: +Features can also be enabled via `config/shopify.php`: ```php return [ @@ -1259,7 +1259,7 @@ return [ When enabled, the plugin fetches Shopify's published store locales during product sync and includes translation data for each non-primary locale in the product's raw data. This lets you surface translated product content (titles, descriptions, etc.). -Translation data is stored in `product.getData()`, keyed by locale — for example, `translations_fr` for French. Each entry is an array of `key`/`value` pairs corresponding to Shopify's translatable resource keys. +Translation data is stored in `product.getData()`, keyed by locale, like `translations_fr` for French. Each entry is an array of `key`/`value` pairs corresponding to Shopify's [translatable resource keys](https://shopify.dev/docs/api/admin-graphql/latest/objects/Translation). ```twig {# Loop over French translations for a product #} @@ -1269,6 +1269,16 @@ Translation data is stored in `product.getData()`, keyed by locale — for examp {% endfor %} ``` +To make this data easier to work with, consider indexing it by `key`: + +```twig +{% set translationsByKey = collect(product.getData()['translations_fr']) + .keyBy('key') + .mapWithKeys((item, k) => { (k): item.value }) %} + +{{ translationsByKey.title ?? product.title }} +``` + ### Emulate Sales Channels Private apps no longer come with a sales channel that allows merchants to selectively expose products to the Craft integration. From f2f6a5754d86f52adeec52dc952afeb5f2bfceb6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 17 Jun 2026 14:06:29 +0100 Subject: [PATCH 08/65] WIP make `shopifyId` and `shopifyGid` consistent across the system --- CHANGELOG-WIP.md | 24 ++++ src/Plugin.php | 4 +- src/collections/VariantCollection.php | 1 + src/elements/Product.php | 2 +- src/elements/db/ProductQuery.php | 4 +- src/fieldlayoutelements/VariantsField.php | 2 +- src/gql/types/Variant.php | 2 - src/handlers/Webhook.php | 4 +- src/helpers/Product.php | 2 +- src/jobs/ProcessBulkOperationData.php | 12 +- src/migrations/Install.php | 17 ++- ..._shopifyId_to_shopifyGid_in_data_table.php | 56 +++++++++ ...to_shopifyGid_in_bulk_operations_table.php | 35 ++++++ src/models/BulkOperation.php | 8 +- src/models/Variant.php | 14 ++- src/records/BulkOperation.php | 2 +- src/records/Product.php | 2 +- src/records/ShopifyData.php | 1 + src/services/Api.php | 2 +- src/services/BulkOperations.php | 26 ++-- src/services/Products.php | 81 ++++++++---- tests/fixtures/BulkOperationsFixture.php | 2 +- tests/fixtures/ShopifyDataFixture.php | 2 +- .../fixtures/data/shopify-bulk-operations.php | 10 +- tests/fixtures/data/shopify-data.php | 118 +++++++++--------- .../jobs/ProcessBulkOperationDataTest.php | 22 ++-- tests/unit/services/BulkOperationsTest.php | 34 ++--- tests/unit/services/ProductsTest.php | 18 +-- 28 files changed, 343 insertions(+), 164 deletions(-) create mode 100644 CHANGELOG-WIP.md create mode 100644 src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php create mode 100644 src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000..0af83114 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,24 @@ +# WIP Release Notes for Shopify 8.0 + +### Extensibility + +- Added `craft\shopify\models\BulkOperation::$shopifyGid`. +- Added `craft\shopify\models\Variant::$shopifyGid`. +- Added `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyGid`. +- Added `craft\shopify\services\BulkOperations::getBulkOperationByShopifyGid()`. +- Added `craft\shopify\services\Products::deleteProductByShopifyGid()`. +- Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. +- Added `craft\shopify\services\Products::syncProductByShopifyGid()`. +- `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- Deprecated `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId`. Use `$bulkOperationShopifyGid` instead. +- Deprecated `craft\shopify\models\BulkOperation::$shopifyId`. Use `$shopifyGid` instead. +- Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead. +- Deprecated `craft\shopify\services\Products::deleteProductByShopifyId()`. Use `deleteProductByShopifyGid()` instead. +- Deprecated `craft\shopify\services\Products::deleteShopifyDataByShopifyId()`. Use `deleteShopifyDataByShopifyGid()` instead. +- Deprecated `craft\shopify\services\Products::syncProductByShopifyId()`. Use `syncProductByShopifyGid()` instead. + +### System + +- The `shopify_data` table's `shopifyId` column has been renamed to `shopifyGid`. A new generated `shopifyId` column (the numeric ID at the end of the GID) has been added. +- The `shopify_bulkoperations` table's `shopifyId` column has been renamed to `shopifyGid`. diff --git a/src/Plugin.php b/src/Plugin.php index a3830138..742c8930 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -450,8 +450,8 @@ private function _registerGarbageCollection(): void 'products.shopifyId', ]) ->from(Table::PRODUCTS . ' products') - ->leftJoin(Table::DATA . ' data', '[[data.shopifyId]] = [[products.shopifyGid]]') - ->where(['data.shopifyId' => null]) + ->leftJoin(Table::DATA . ' data', '[[data.shopifyGid]] = [[products.shopifyGid]]') + ->where(['data.shopifyGid' => null]) ->all(); $shopifyIds = ArrayHelper::getColumn($shopifyProductElementsMissingData, 'shopifyId'); diff --git a/src/collections/VariantCollection.php b/src/collections/VariantCollection.php index a489f649..fd381c32 100644 --- a/src/collections/VariantCollection.php +++ b/src/collections/VariantCollection.php @@ -42,6 +42,7 @@ public static function make($items = []) $item = Craft::createObject([ 'class' => Variant::class, 'id' => $item->id, + 'shopifyGid' => $item->shopifyGid, 'shopifyId' => $item->shopifyId, 'type' => $item->type, 'parentId' => $item->parentId, diff --git a/src/elements/Product.php b/src/elements/Product.php index 6c155700..5916582e 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -823,7 +823,7 @@ public function afterDelete(): void { // Remove all the product shopify data if ($this->shopifyGid && $this->getIsCanonical()) { - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->shopifyGid); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->shopifyGid); } parent::afterDelete(); diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index 66fc57bb..8f295c6c 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -396,8 +396,8 @@ protected function beforePrepare(): bool // join standard product element table that only contains the shopifyId $this->joinElementTable('shopify_products'); - $this->query->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyId]] = [[shopify_products.shopifyGid]]')); - $this->subQuery->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyId]] = [[shopify_products.shopifyGid]]')); + $this->query->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyGid]] = [[shopify_products.shopifyGid]]')); + $this->subQuery->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyGid]] = [[shopify_products.shopifyGid]]')); $this->query->select([ 'shopify_products.shopifyId', diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php index cab9e0e1..72b7f284 100644 --- a/src/fieldlayoutelements/VariantsField.php +++ b/src/fieldlayoutelements/VariantsField.php @@ -63,7 +63,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa ]; foreach ($variants as $variant) { - $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), str_replace('gid://shopify/ProductVariant/', '', $variant->shopifyId)); + $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), $variant->shopifyId); $title = $variant->title; $sku = $variant->sku; diff --git a/src/gql/types/Variant.php b/src/gql/types/Variant.php index b80276cc..96579ceb 100644 --- a/src/gql/types/Variant.php +++ b/src/gql/types/Variant.php @@ -56,13 +56,11 @@ public static function getFieldDefinitions(): array 'name' => 'shopifyId', 'type' => Type::string(), 'description' => 'Shopify ID of the variant.', - 'resolve' => fn(VariantElement $source) => str_replace('gid://shopify/ProductVariant/', '', $source->shopifyId), ], 'shopifyGid' => [ 'name' => 'shopifyGid', 'type' => Type::string(), 'description' => 'Shopify GID of the variant.', - 'resolve' => fn(VariantElement $source) => $source->shopifyId, ], 'title' => [ 'name' => 'title', diff --git a/src/handlers/Webhook.php b/src/handlers/Webhook.php index 1b31c923..700944d8 100644 --- a/src/handlers/Webhook.php +++ b/src/handlers/Webhook.php @@ -24,10 +24,10 @@ public function handle(string $topic, string $shop, array $body): void switch ($topic) { case Topics::PRODUCTS_UPDATE: case Topics::PRODUCTS_CREATE: - Plugin::getInstance()->getProducts()->syncProductByShopifyId($body['id']); + Plugin::getInstance()->getProducts()->syncProductByShopifyGid($body['id']); break; case Topics::PRODUCTS_DELETE: - Plugin::getInstance()->getProducts()->deleteProductByShopifyId($body['id']); + Plugin::getInstance()->getProducts()->deleteProductByShopifyGid($body['id']); break; case Topics::INVENTORY_ITEMS_UPDATE: Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']); diff --git a/src/helpers/Product.php b/src/helpers/Product.php index abef6ce9..0d53946f 100644 --- a/src/helpers/Product.php +++ b/src/helpers/Product.php @@ -131,7 +131,7 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet // This is the date updated in the database which represents the last time it was updated from a Shopify webhook or sync. /** @var ShopifyData $productData */ - $productData = ShopifyData::find()->where(['shopifyId' => $product->shopifyGid])->one(); + $productData = ShopifyData::find()->where(['shopifyGid' => $product->shopifyGid])->one(); $dateUpdated = DateTimeHelper::toDateTime($productData->dateUpdated); $now = new \DateTime(); $diff = $now->diff($dateUpdated); diff --git a/src/jobs/ProcessBulkOperationData.php b/src/jobs/ProcessBulkOperationData.php index 0799c122..23655b9a 100644 --- a/src/jobs/ProcessBulkOperationData.php +++ b/src/jobs/ProcessBulkOperationData.php @@ -23,7 +23,7 @@ class ProcessBulkOperationData extends BaseBatchedJob /** * @var string */ - public string $bulkOperationShopifyId; + public string $bulkOperationShopifyGid; /** * @var string @@ -110,7 +110,7 @@ protected function processItem(mixed $item): void // Find data records based on their Shopify ID and parent ID. This is to avoid overwriting // records with the same ID but different parents (e.g. Metafields, Images etc). - $record = ShopifyData::findOne(['shopifyId' => $item['id'], 'parentId' => $parentId]); + $record = ShopifyData::findOne(['shopifyGid' => $item['id'], 'parentId' => $parentId]); if (!$record) { $record = new ShopifyData(); } @@ -119,7 +119,7 @@ protected function processItem(mixed $item): void $parts = explode('/', str_replace('gid://shopify/', '', $item['id'])); $type = $parts[0]; - $record->shopifyId = $item['id']; + $record->shopifyGid = $item['id']; $record->type = $type; $record->data = $item; $record->parentId = $item['__parentId'] ?? null; @@ -139,7 +139,7 @@ protected function before(): void parent::before(); // Make sure bulk op is marked as processing - $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId); + $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid); if (!$bulkOperation) { return; @@ -153,7 +153,7 @@ protected function before(): void if ($this->clearData === BulkOperationRecord::CLEAR_DATA_ALL) { ShopifyData::deleteAll(); } elseif ($this->clearData !== BulkOperationRecord::CLEAR_DATA_NONE) { - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->clearData); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->clearData); } } @@ -165,7 +165,7 @@ protected function after(): void parent::after(); // Mark bulk op as completed - $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId); + $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid); if (!$bulkOperation) { return; diff --git a/src/migrations/Install.php b/src/migrations/Install.php index fffe0c93..66ccf78b 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -57,7 +57,7 @@ public function createTables(): void $this->archiveTableIfExists(Table::DATA); $this->createTable(Table::DATA, [ 'id' => $this->primaryKey(), - 'shopifyId' => $this->string(), + 'shopifyGid' => $this->string(), 'type' => $this->string(), 'data' => $this->json(), 'parentId' => $this->string(), @@ -69,7 +69,7 @@ public function createTables(): void $this->archiveTableIfExists(Table::BULK_OPERATIONS); $this->createTable(Table::BULK_OPERATIONS, [ 'id' => $this->primaryKey(), - 'shopifyId' => $this->string(), + 'shopifyGid' => $this->string(), 'url' => $this->text(), 'objectCount' => $this->integer(), 'query' => $this->text(), @@ -139,6 +139,17 @@ public function createGeneratedColumns(): void $db->quoteColumnName($alias) . ' ' . $qb->getColumnType($this->integer()) . " GENERATED ALWAYS AS (" . $expression . ") STORED;"); } + + // Derived shopifyId — the numeric ID at the end of the GID (e.g. "7136060145715" from "gid://shopify/Product/7136060145715") + if ($db->getIsPgsql()) { + $shopifyIdExpression = "regexp_replace(\"shopifyGid\", '^.*/', '')"; + } else { + $shopifyIdExpression = "SUBSTRING_INDEX(`shopifyGid`, '/', -1)"; + } + + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . + $db->quoteColumnName('shopifyId') . ' ' . $qb->getColumnType($this->string()) . " GENERATED ALWAYS AS (" . + $shopifyIdExpression . ") STORED;"); } /** @@ -148,7 +159,7 @@ public function createIndexes(): void { $this->createIndex(null, Table::PRODUCTS, ['shopifyId'], false); $this->createIndex(null, Table::PRODUCTS, ['shopifyGid'], false); - $this->createIndex(null, Table::DATA, ['shopifyId'], false); + $this->createIndex(null, Table::DATA, ['shopifyGid'], false); $this->createIndex(null, Table::DATA, ['parentId'], false); } diff --git a/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php b/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php new file mode 100644 index 00000000..3ba15894 --- /dev/null +++ b/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php @@ -0,0 +1,56 @@ +db->columnExists(Table::DATA, 'shopifyGid')) { + return true; + } + + $db = $this->getDb(); + $qb = $db->getQueryBuilder(); + + // Drop the existing index on shopifyId before renaming + $this->dropIndexIfExists(Table::DATA, ['shopifyId'], false); + + // Rename shopifyId → shopifyGid + $this->renameColumn(Table::DATA, 'shopifyId', 'shopifyGid'); + + // Recreate the index on the renamed column + $this->createIndex(null, Table::DATA, ['shopifyGid'], false); + + // Add generated shopifyId column — the numeric ID at the end of the GID + if ($db->getIsPgsql()) { + $expression = "regexp_replace(\"shopifyGid\", '^.*/', '')"; + } else { + $expression = "SUBSTRING_INDEX(`shopifyGid`, '/', -1)"; + } + + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . + $db->quoteColumnName('shopifyId') . ' ' . $qb->getColumnType($this->string()) . " GENERATED ALWAYS AS (" . + $expression . ") STORED;"); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table cannot be reverted.\n"; + return false; + } +} diff --git a/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php b/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php new file mode 100644 index 00000000..72b243f5 --- /dev/null +++ b/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php @@ -0,0 +1,35 @@ +db->columnExists(Table::BULK_OPERATIONS, 'shopifyGid')) { + return true; + } + + $this->renameColumn(Table::BULK_OPERATIONS, 'shopifyId', 'shopifyGid'); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/BulkOperation.php b/src/models/BulkOperation.php index 0e41d947..3bf4c1ca 100644 --- a/src/models/BulkOperation.php +++ b/src/models/BulkOperation.php @@ -26,9 +26,9 @@ class BulkOperation extends Model public ?int $id = null; /** - * @var string|null The Shopify ID of the bulk operation. + * @var string|null The Shopify GID of the bulk operation (e.g. "gid://shopify/BulkOperation/123456789"). */ - public ?string $shopifyId = null; + public ?string $shopifyGid = null; /** * @var string|null The URL of the bulk operation data. @@ -80,8 +80,8 @@ protected function defineRules(): array $rules = parent::defineRules(); $rules[] = [['id', 'objectCount'], 'number', 'integerOnly' => true]; - $rules[] = [['shopifyId', 'url'], 'string']; - $rules[] = [['id', 'shopifyId', 'url', 'objectCount', 'status', 'shopifyStatus', 'query', 'dateCreated', 'dateUpdated'], 'safe']; + $rules[] = [['shopifyGid', 'url'], 'string']; + $rules[] = [['id', 'shopifyGid', 'url', 'objectCount', 'status', 'shopifyStatus', 'query', 'dateCreated', 'dateUpdated'], 'safe']; return $rules; } diff --git a/src/models/Variant.php b/src/models/Variant.php index 688e4990..991ca967 100644 --- a/src/models/Variant.php +++ b/src/models/Variant.php @@ -16,6 +16,7 @@ /** * Variant model. * + * @property-read string $shopifyGid * @property-read string $shopifyId * @property-read string $title * @property-read string $sku @@ -31,7 +32,12 @@ class Variant extends Model public ?int $id = null; /** - * @var string|null The Shopify ID of the variant. + * @var string|null The Shopify GID of the variant (e.g. "gid://shopify/ProductVariant/123456789"). + */ + public ?string $shopifyGid = null; + + /** + * @var string|null The numeric Shopify ID of the variant (last segment of the GID). */ public ?string $shopifyId = null; @@ -103,7 +109,7 @@ protected function defineRules(): array { $rules = parent::defineRules(); - $rules[] = [['id', 'shopifyId', 'type', 'parentId', 'data', 'dateCreated', 'dateUpdated', 'uid'], 'safe']; + $rules[] = [['id', 'shopifyGid', 'shopifyId', 'type', 'parentId', 'data', 'dateCreated', 'dateUpdated', 'uid'], 'safe']; return $rules; } @@ -165,7 +171,7 @@ public function setMetafields(string|array $value): void */ public function getMetafields(): array { - if (!$this->shopifyId) { + if (!$this->shopifyGid) { return []; } @@ -173,7 +179,7 @@ public function getMetafields(): array return $this->_metaFields; } - $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyId); + $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid); $metafields = $data ->mapWithKeys(function($d) { diff --git a/src/records/BulkOperation.php b/src/records/BulkOperation.php index 258649c8..0061e04c 100644 --- a/src/records/BulkOperation.php +++ b/src/records/BulkOperation.php @@ -17,7 +17,7 @@ * @since 6.0.0 * * @property int $id - * @property string $shopifyId + * @property string $shopifyGid * @property string $url * @property string $status * @property string $shopifyStatus diff --git a/src/records/Product.php b/src/records/Product.php index 36459106..46cc566a 100644 --- a/src/records/Product.php +++ b/src/records/Product.php @@ -39,6 +39,6 @@ public function getElement(): ActiveQueryInterface public function getData(): ActiveQueryInterface { - return $this->hasOne(ShopifyData::class, ['shopifyGid' => 'shopifyId']); + return $this->hasOne(ShopifyData::class, ['shopifyGid' => 'shopifyGid']); } } diff --git a/src/records/ShopifyData.php b/src/records/ShopifyData.php index 59625b0f..75da9387 100644 --- a/src/records/ShopifyData.php +++ b/src/records/ShopifyData.php @@ -17,6 +17,7 @@ * @since 6.0.0 * * @property int $id + * @property string $shopifyGid * @property string $shopifyId * @property string $type * @property string|array $data diff --git a/src/services/Api.php b/src/services/Api.php index 89f4b415..ec7cb293 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -176,7 +176,7 @@ public function getShop(bool $update = false): ?array $shopRecord = new ShopifyData(); } - $shopRecord->shopifyId = $response['id']; + $shopRecord->shopifyGid = $response['id']; $shopRecord->type = 'Shop'; $shopRecord->data = $response; diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index 8e6cb586..cdb9e1db 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -66,14 +66,26 @@ public function getAllBulkOperations(): Collection return collect($bulkOps); } + /** + * @param string $gid + * @return BulkOperation|null + * @throws InvalidConfigException + * @since 8.0.0 + */ + public function getBulkOperationByShopifyGid(string $gid): ?BulkOperation + { + return $this->getAllBulkOperations()->firstWhere('shopifyGid', $gid); + } + /** * @param string $shopifyId * @return BulkOperation|null * @throws InvalidConfigException + * @deprecated in 8.0.0. Use [[getBulkOperationByShopifyGid()]] instead. */ public function getBulkOperationByShopifyId(string $shopifyId): ?BulkOperation { - return $this->getAllBulkOperations()->firstWhere('shopifyId', $shopifyId); + return $this->getBulkOperationByShopifyGid($shopifyId); } /** @@ -211,7 +223,7 @@ public function nextBulkOperation(): bool } $bulkOperation->shopifyStatus = $op['status']; - $bulkOperation->shopifyId = $op['id']; + $bulkOperation->shopifyGid = $op['id']; $this->saveBulkOperation($bulkOperation); return true; @@ -237,7 +249,7 @@ public function handleBulkOperationFinished(array $payload): void } // Load our local record of the bulk op: - $bulkOperation = $this->getBulkOperationByShopifyId($payload['admin_graphql_api_id']); + $bulkOperation = $this->getBulkOperationByShopifyGid($payload['admin_graphql_api_id']); if (!$bulkOperation) { // Ok... maybe it was initiated for a different environment? @@ -321,7 +333,7 @@ public function queueNextBulkOperation(): bool /** @var BulkOperation $bulkOperation */ $bulkOperation = Craft::createObject(array_merge($nextToProcess, ['class' => BulkOperation::class])); if (!Queue::push(new ProcessBulkOperationData([ - 'bulkOperationShopifyId' => $bulkOperation->shopifyId, + 'bulkOperationShopifyGid' => $bulkOperation->shopifyGid, 'dataUrl' => $bulkOperation->url, 'objectCount' => $bulkOperation->objectCount, 'clearData' => $bulkOperation->clearData, @@ -351,7 +363,7 @@ public function saveBulkOperation(BulkOperation $bulkOperation, bool $runValidat if ($bulkOperation->id) { $record = BulkOperationRecord::findOne($bulkOperation->id); } else { - $record = BulkOperationRecord::findOne(['shopifyId' => $bulkOperation->shopifyId]); + $record = BulkOperationRecord::findOne(['shopifyGid' => $bulkOperation->shopifyGid]); } if (!$record) { @@ -365,7 +377,7 @@ public function saveBulkOperation(BulkOperation $bulkOperation, bool $runValidat } $record->clearData = $bulkOperation->clearData; - $record->shopifyId = $bulkOperation->shopifyId; + $record->shopifyGid = $bulkOperation->shopifyGid; $record->url = $bulkOperation->url; $record->objectCount = $bulkOperation->objectCount; $record->query = $bulkOperation->query; @@ -445,7 +457,7 @@ private function _createBulkOperationQuery(): Query 'id', 'objectCount', 'query', - 'shopifyId', + 'shopifyGid', 'status', 'shopifyStatus', 'url', diff --git a/src/services/Products.php b/src/services/Products.php index 52483321..9ad9af73 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -58,16 +58,29 @@ class Products extends Component */ public const EVENT_BEFORE_SYNCHRONIZE_PRODUCT = 'beforeSynchronizeProduct'; + /** + * @param string $gid + * @return void + * @throws InvalidConfigException + * @throws \yii\db\Exception + * @since 8.0.0 + */ + public function syncProductByShopifyGid(string $gid): void + { + $gid = $this->normalizeShopifyGid($gid); + Plugin::getInstance()->getBulkOperations()->createBulkOperation((string)Plugin::getInstance()->getApi()->getProductGql($gid), $gid); + } + /** * @param string $id * @return void * @throws InvalidConfigException * @throws \yii\db\Exception + * @deprecated in 8.0.0. Use [[syncProductByShopifyGid()]] instead. */ public function syncProductByShopifyId(string $id): void { - $shopifyId = $this->normalizeShopifyGid($id); - Plugin::getInstance()->getBulkOperations()->createBulkOperation((string)Plugin::getInstance()->getApi()->getProductGql($id), $shopifyId); + $this->syncProductByShopifyGid($id); } /** @@ -100,7 +113,7 @@ public function syncProductByInventoryItemId($id): void $productId = $item['variant']['product']['id']; - $this->syncProductByShopifyId($productId); + $this->syncProductByShopifyGid($productId); } /** @@ -178,52 +191,62 @@ public function normalizeShopifyGid(string $shopifyId, string $type = 'Product') } /** - * Deletes a product element by the Shopify ID. + * Deletes a product element by the Shopify GID. * - * @param $id + * @param string $gid * @return void * @throws \Throwable * @throws StaleObjectException + * @since 8.0.0 */ - public function deleteProductByShopifyId($id): void + public function deleteProductByShopifyGid(string $gid): void { - if ($id) { - if ($product = Product::find()->shopifyId($id)->one()) { + if ($gid) { + if ($product = Product::find()->shopifyId($gid)->one()) { // We hard delete because it will have been hard deleted in Shopify Craft::$app->getElements()->deleteElement($product, true); } - // Delete data in shopify data table - // Delete the product data - $shopifyId = $this->normalizeShopifyGid($id); - $this->deleteShopifyDataByShopifyId($shopifyId); + $this->deleteShopifyDataByShopifyGid($this->normalizeShopifyGid($gid)); } } /** - * @param string $shopifyId + * @param $id + * @return void + * @throws \Throwable + * @throws StaleObjectException + * @deprecated in 8.0.0. Use [[deleteProductByShopifyGid()]] instead. + */ + public function deleteProductByShopifyId($id): void + { + $this->deleteProductByShopifyGid($id); + } + + /** + * @param string $gid * @return void * @throws StaleObjectException * @throws \Throwable - * @since 6.0.0 + * @since 8.0.0 */ - public function deleteShopifyDataByShopifyId(string $shopifyId): void + public function deleteShopifyDataByShopifyGid(string $gid): void { - // Support both id and gid - $shopifyId = $this->normalizeShopifyGid($shopifyId); + // Support both numeric ID and GID + $gid = $this->normalizeShopifyGid($gid); /** @var ShopifyData|null $shopifyData */ - $shopifyData = ShopifyData::find()->where(['shopifyId' => $shopifyId])->one(); + $shopifyData = ShopifyData::find()->where(['shopifyGid' => $gid])->one(); // Delete if possible $shopifyData?->delete(); // Delete any child data that may still exist /** @var ShopifyData[] $shopifyData */ - $shopifyData = ShopifyData::find()->where(['parentId' => $shopifyId])->all(); + $shopifyData = ShopifyData::find()->where(['parentId' => $gid])->all(); $childIds = []; foreach ($shopifyData as $data) { - $childIds[] = $data->shopifyId; + $childIds[] = $data->shopifyGid; $data->delete(); } @@ -233,12 +256,24 @@ public function deleteShopifyDataByShopifyId(string $shopifyId): void /** @var ShopifyData[] $shopifyData */ $shopifyData = ShopifyData::find()->where(['parentId' => $childId])->all(); foreach ($shopifyData as $data) { - $childIds[] = $data->shopifyId; + $childIds[] = $data->shopifyGid; $data->delete(); } } } + /** + * @param string $shopifyId + * @return void + * @throws StaleObjectException + * @throws \Throwable + * @deprecated in 8.0.0. Use [[deleteShopifyDataByShopifyGid()]] instead. + */ + public function deleteShopifyDataByShopifyId(string $shopifyId): void + { + $this->deleteShopifyDataByShopifyGid($shopifyId); + } + /** * @param array|Product[] $products * @return array @@ -290,7 +325,7 @@ public function eagerLoadVariantsForProducts(array $products): array $variantsByProductId = []; $return = $this->_eagerLoadTypeOnProducts($products, 'ProductVariant', function($product, $rows) use (&$variantsByProductId, &$variantIds) { foreach ($rows as $row) { - $variantIds[] = $row->shopifyId; + $variantIds[] = $row->shopifyGid; } $variantsByProductId[$product->shopifyGid] = $rows; @@ -310,7 +345,7 @@ public function eagerLoadVariantsForProducts(array $products): array if ($metafieldsData->isNotEmpty()) { $variants->map(function(Variant$variant) use ($metafieldsData) { - $metafields = $metafieldsData->get($variant->shopifyId); + $metafields = $metafieldsData->get($variant->shopifyGid); if (!empty($metafields)) { $variant->setMetafields(collect($metafields)->mapWithKeys(function($d) { $data = Json::decodeIfJson($d->data); diff --git a/tests/fixtures/BulkOperationsFixture.php b/tests/fixtures/BulkOperationsFixture.php index 1b4fd2f9..486b52ac 100644 --- a/tests/fixtures/BulkOperationsFixture.php +++ b/tests/fixtures/BulkOperationsFixture.php @@ -26,7 +26,7 @@ public function load(): void foreach ($rows as $key => $row) { $model = new BulkOperation(); - $model->shopifyId = $row['shopifyId']; + $model->shopifyGid = $row['shopifyGid']; $model->url = $row['url']; $model->objectCount = (int)($row['objectCount'] ?? 0); $model->query = $row['query'] ?? null; diff --git a/tests/fixtures/ShopifyDataFixture.php b/tests/fixtures/ShopifyDataFixture.php index 62144fbe..db8892cb 100644 --- a/tests/fixtures/ShopifyDataFixture.php +++ b/tests/fixtures/ShopifyDataFixture.php @@ -29,7 +29,7 @@ public function load(): void foreach ($rows as $row) { $uid = $row['uid']; \Yii::$app->db->createCommand()->insert(Table::DATA, [ - 'shopifyId' => $row['shopifyId'], + 'shopifyGid' => $row['shopifyGid'], 'type' => $row['type'], 'data' => is_array($row['data']) ? json_encode($row['data']) : $row['data'], 'parentId' => $row['parentId'], diff --git a/tests/fixtures/data/shopify-bulk-operations.php b/tests/fixtures/data/shopify-bulk-operations.php index d3774db8..4725c0d8 100644 --- a/tests/fixtures/data/shopify-bulk-operations.php +++ b/tests/fixtures/data/shopify-bulk-operations.php @@ -3,7 +3,7 @@ return [ 'op_completed_74' => [ 'id' => 74, - 'shopifyId' => 'gid://shopify/BulkOperation/4848685842483', + 'shopifyGid' => 'gid://shopify/BulkOperation/4848685842483', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/45bd3bca5ff2b0445c4ee86c7b6a?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781775894&Signature=gYorZwhi9arbpjvRo5p%2BqkRn9NYhDNwmi6ZJ%2FHSH3BgiQqu62pByOOUpZ%2BG3Qe4QI%2Frc%2F8XJYoSudrjrRBtnqn%2Fess73avZYDa7%2BmG4qPyJ%2BToJu04H5xYo%2FiIbSj6mJsHJJ1td2w%2B0O3hbbcPmVFaEZkR8vZpfa0MkC8aPO4VFbj%2FfnJdTDr5kmLV5E6PdlFy8vfKCBsXoj4Hh0dDImi9IFSEjEx80hc7%2FXtxBe374vam%2BM%2BGY0KCFglfNmnWyPjGJtnjXciRprOkCJ%2Fcz5vlNbYiR%2FAdRKF6i51HeztiBmD8vCrstiktNug9wruwYcawmGqQiXa4oJMLAWKOe4lg%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4848685842483.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4848685842483.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 60, 'query' => 'query { products(query: "id:6656149192755") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor } } } }', @@ -16,7 +16,7 @@ ], 'op_completed_73' => [ 'id' => 73, - 'shopifyId' => 'gid://shopify/BulkOperation/4846181023795', + 'shopifyGid' => 'gid://shopify/BulkOperation/4846181023795', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/0b38d4b425e19b4600ddc7d6c366?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781705438&Signature=PMTzLXL%2B7Nb%2B5RCPXypekUy8BmX1AZTfqIxT0TkwKe7%2FJ4T5HRCuZySB4gZN5wCq%2Fe39DKtGFKuJwscJOoWp4tSKGCnPNtZECvzNnZKo8rDZurffaTFOWsHIgJ2LqlMYF%2BPm675YY0Lh%2FfWDcp5LgWBGCgxRHwGa2YcAyRxE8jJBFC4b8wXmJEQ8bnEKOB5AQpeVK5p%2B7PF5kSXSMp%2F83KAUETJUnY2KtlczABAo8JcyoHBVj7n6pZhVHal%2Bz1HYdC8rZZzO%2FsA3iJ2mX7z%2FrUFaZAoGIrazPJXninpLyA6wz%2F4y41HDV390ekMj6va4tu%2BIA1VuZo8MN4FHnelnSQ%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846181023795.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846181023795.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 3, 'query' => 'query { products(query: "id:7136093208627") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', @@ -29,7 +29,7 @@ ], 'op_completed_72' => [ 'id' => 72, - 'shopifyId' => 'gid://shopify/BulkOperation/4846126170163', + 'shopifyGid' => 'gid://shopify/BulkOperation/4846126170163', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/1261ad672da99ee3b988a510cef2?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781703757&Signature=anJScMX3XZ4HGBwJbEnGsyQElHcJG%2BplwGMocdYEL%2FTJoiF13cSQAV6CKKiQ7hfzlrkuHt1QQAZl68TX38SJLtvVBXE64hA2C7eLAb9ncpJvBCcXo54TjCGkyns54dBYjsmNtOXdHBuIbAsjbFQ52Elw3QZQV57yjITmqEgDkHdqpgb011C0bxX4lpwMW4e%2FpV0JgITFeYJrfRNcFEgu6RzsWQvuj8114VSvqgHGN2k50AVyUtvY9b39wsvZTNOP9%2FNqdary%2BO0RPY7NqmQUF%2BHD6cBm%2F2EF%2F%2B0RsfpdUJ3loWUtXAuOH9kIZP2DZFnoxoAYu6QGjSNLP01V%2F3hvSA%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846126170163.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846126170163.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 3, 'query' => 'query { products(query: "id:7136068075571") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', @@ -42,7 +42,7 @@ ], 'op_completed_71' => [ 'id' => 71, - 'shopifyId' => 'gid://shopify/BulkOperation/4846010433587', + 'shopifyGid' => 'gid://shopify/BulkOperation/4846010433587', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/6a97ef72dca29699d67ba70d3e8b?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781700339&Signature=mfZ76fGjrnk9G04sllkiktezTLFsLoikUhqftMHP4CNt5fVgaGIQY1%2Buvh2lVKLXGlIH%2F44WXiAZD%2F0gK28gRnT0kBLcENPR3ZmoAmpFHDGbB4MKEv6bUkrJv7wlxZD0ZilyYgnVLEyNuV3CvLgqe9HRz3S8bfeIH3jfxt5qxCzfcdtNIoRg0qmkageYF6drLCdCQ7y7UPvCU9e0De9HEuiaycfFty6Gyc2%2BO1pFCxbQu0I6hZJbSbney3QJvdatNo8j05zWFFalPI9iPXUZnHxGm6Sc4ISP2FZCcutcJ9r617%2F5uSYSsHi%2F%2F5e0Deb%2FHAEQJYiZJk8YXHL7tvNqHQ%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846010433587.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846010433587.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 3, 'query' => 'query { products(query: "id:7136099729459") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', @@ -55,7 +55,7 @@ ], 'op_completed_70' => [ 'id' => 70, - 'shopifyId' => 'gid://shopify/BulkOperation/4845996933171', + 'shopifyGid' => 'gid://shopify/BulkOperation/4845996933171', 'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/45e4ebfb3778f9f27c5709136189?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781700017&Signature=XOm1n1r681pq%2B7iQpcLHD%2FMmYOqv8Fa33LOcE8LSdYLezdRDBR6wiYZ2jCNvtA5nOGBzUe3XDadhTbaxYVqlIaeZDx1HBB14wPfHjbLmNU5hRXa0094tQiw9IU23iCi4lM%2Bc2PX5SrC5WJbFwcDrl%2BiOzrazNpjgp5rVnW4QWPepNaN7TBeiHMHBqxNnY7FxCEi2FQxK6cchM1Rjuro0QmtNufRxiuw9vhxbjf8b6VOtZx4agdnNms3Rhjioq7191qfdeLvzaz2aepXoJd%2BhAVAmJo5wgMkgRF2wddgCtrYe6SHFdxP%2BCrlV1XHpbOQiI1G52%2BQorUps3EOlX8rHfg%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4845996933171.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4845996933171.jsonl&response-content-type=application%2Fjsonl', 'objectCount' => 1880, 'query' => 'query { products { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }', diff --git a/tests/fixtures/data/shopify-data.php b/tests/fixtures/data/shopify-data.php index 183d5813..7b1aca5d 100644 --- a/tests/fixtures/data/shopify-data.php +++ b/tests/fixtures/data/shopify-data.php @@ -3,7 +3,7 @@ return [ 'Product_product_7136060145715' => [ 'id' => 64533, - 'shopifyId' => 'gid://shopify/Product/7136060145715', + 'shopifyGid' => 'gid://shopify/Product/7136060145715', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136060145715', @@ -126,7 +126,7 @@ ], 'Product_product_7136060964915' => [ 'id' => 64581, - 'shopifyId' => 'gid://shopify/Product/7136060964915', + 'shopifyGid' => 'gid://shopify/Product/7136060964915', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136060964915', @@ -262,7 +262,7 @@ ], 'Product_product_7136062865459' => [ 'id' => 64674, - 'shopifyId' => 'gid://shopify/Product/7136062865459', + 'shopifyGid' => 'gid://shopify/Product/7136062865459', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136062865459', @@ -336,7 +336,7 @@ ], 'Product_product_7136074399795' => [ 'id' => 65044, - 'shopifyId' => 'gid://shopify/Product/7136074399795', + 'shopifyGid' => 'gid://shopify/Product/7136074399795', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136074399795', @@ -396,7 +396,7 @@ ], 'Product_product_7136075251763' => [ 'id' => 65080, - 'shopifyId' => 'gid://shopify/Product/7136075251763', + 'shopifyGid' => 'gid://shopify/Product/7136075251763', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136075251763', @@ -456,7 +456,7 @@ ], 'Product_product_7136076070963' => [ 'id' => 65107, - 'shopifyId' => 'gid://shopify/Product/7136076070963', + 'shopifyGid' => 'gid://shopify/Product/7136076070963', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136076070963', @@ -515,7 +515,7 @@ ], 'Product_product_7136089669683' => [ 'id' => 65620, - 'shopifyId' => 'gid://shopify/Product/7136089669683', + 'shopifyGid' => 'gid://shopify/Product/7136089669683', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136089669683', @@ -574,7 +574,7 @@ ], 'Product_product_7136090816563' => [ 'id' => 65644, - 'shopifyId' => 'gid://shopify/Product/7136090816563', + 'shopifyGid' => 'gid://shopify/Product/7136090816563', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136090816563', @@ -633,7 +633,7 @@ ], 'Product_product_7136093863987' => [ 'id' => 65773, - 'shopifyId' => 'gid://shopify/Product/7136093863987', + 'shopifyGid' => 'gid://shopify/Product/7136093863987', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136093863987', @@ -688,7 +688,7 @@ ], 'Product_product_7136099500083' => [ 'id' => 66025, - 'shopifyId' => 'gid://shopify/Product/7136099500083', + 'shopifyGid' => 'gid://shopify/Product/7136099500083', 'type' => 'Product', 'data' => [ 'id' => 'gid://shopify/Product/7136099500083', @@ -749,7 +749,7 @@ ], 'MediaImage_mediaimage_23117943373875' => [ 'id' => 64534, - 'shopifyId' => 'gid://shopify/MediaImage/23117943373875', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943373875', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943373875', @@ -772,7 +772,7 @@ ], 'MediaImage_mediaimage_23117943406643' => [ 'id' => 64535, - 'shopifyId' => 'gid://shopify/MediaImage/23117943406643', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943406643', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943406643', @@ -795,7 +795,7 @@ ], 'MediaImage_mediaimage_23117943439411' => [ 'id' => 64536, - 'shopifyId' => 'gid://shopify/MediaImage/23117943439411', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943439411', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943439411', @@ -818,7 +818,7 @@ ], 'MediaImage_mediaimage_23117943472179' => [ 'id' => 64537, - 'shopifyId' => 'gid://shopify/MediaImage/23117943472179', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943472179', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943472179', @@ -841,7 +841,7 @@ ], 'MediaImage_mediaimage_23117943504947' => [ 'id' => 64538, - 'shopifyId' => 'gid://shopify/MediaImage/23117943504947', + 'shopifyGid' => 'gid://shopify/MediaImage/23117943504947', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117943504947', @@ -864,7 +864,7 @@ ], 'ProductVariant_productvariant_41966390083635' => [ 'id' => 64539, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390083635', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390083635', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390083635', @@ -919,7 +919,7 @@ ], 'ProductVariant_productvariant_41966390116403' => [ 'id' => 64540, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390116403', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390116403', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390116403', @@ -974,7 +974,7 @@ ], 'ProductVariant_productvariant_41966390149171' => [ 'id' => 64541, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390149171', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390149171', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390149171', @@ -1029,7 +1029,7 @@ ], 'ProductVariant_productvariant_41966390181939' => [ 'id' => 64542, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390181939', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390181939', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390181939', @@ -1084,7 +1084,7 @@ ], 'ProductVariant_productvariant_41966390214707' => [ 'id' => 64543, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390214707', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390214707', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390214707', @@ -1139,7 +1139,7 @@ ], 'ProductVariant_productvariant_41966390247475' => [ 'id' => 64544, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390247475', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390247475', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390247475', @@ -1194,7 +1194,7 @@ ], 'ProductVariant_productvariant_41966390280243' => [ 'id' => 64545, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390280243', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390280243', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390280243', @@ -1249,7 +1249,7 @@ ], 'ProductVariant_productvariant_41966390313011' => [ 'id' => 64546, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390313011', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390313011', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390313011', @@ -1304,7 +1304,7 @@ ], 'ProductVariant_productvariant_41966390345779' => [ 'id' => 64547, - 'shopifyId' => 'gid://shopify/ProductVariant/41966390345779', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966390345779', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966390345779', @@ -1359,7 +1359,7 @@ ], 'MediaImage_mediaimage_23117946912819' => [ 'id' => 64582, - 'shopifyId' => 'gid://shopify/MediaImage/23117946912819', + 'shopifyGid' => 'gid://shopify/MediaImage/23117946912819', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117946912819', @@ -1382,7 +1382,7 @@ ], 'MediaImage_mediaimage_23117946945587' => [ 'id' => 64583, - 'shopifyId' => 'gid://shopify/MediaImage/23117946945587', + 'shopifyGid' => 'gid://shopify/MediaImage/23117946945587', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117946945587', @@ -1405,7 +1405,7 @@ ], 'MediaImage_mediaimage_23117946978355' => [ 'id' => 64584, - 'shopifyId' => 'gid://shopify/MediaImage/23117946978355', + 'shopifyGid' => 'gid://shopify/MediaImage/23117946978355', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117946978355', @@ -1428,7 +1428,7 @@ ], 'MediaImage_mediaimage_23117947011123' => [ 'id' => 64585, - 'shopifyId' => 'gid://shopify/MediaImage/23117947011123', + 'shopifyGid' => 'gid://shopify/MediaImage/23117947011123', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117947011123', @@ -1451,7 +1451,7 @@ ], 'MediaImage_mediaimage_23117947043891' => [ 'id' => 64586, - 'shopifyId' => 'gid://shopify/MediaImage/23117947043891', + 'shopifyGid' => 'gid://shopify/MediaImage/23117947043891', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117947043891', @@ -1474,7 +1474,7 @@ ], 'ProductVariant_productvariant_41966393098291' => [ 'id' => 64587, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393098291', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393098291', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393098291', @@ -1529,7 +1529,7 @@ ], 'ProductVariant_productvariant_41966393131059' => [ 'id' => 64588, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393131059', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393131059', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393131059', @@ -1584,7 +1584,7 @@ ], 'ProductVariant_productvariant_41966393163827' => [ 'id' => 64589, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393163827', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393163827', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393163827', @@ -1639,7 +1639,7 @@ ], 'ProductVariant_productvariant_41966393196595' => [ 'id' => 64590, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393196595', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393196595', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393196595', @@ -1694,7 +1694,7 @@ ], 'ProductVariant_productvariant_41966393229363' => [ 'id' => 64591, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393229363', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393229363', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393229363', @@ -1749,7 +1749,7 @@ ], 'ProductVariant_productvariant_41966393262131' => [ 'id' => 64592, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393262131', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393262131', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393262131', @@ -1804,7 +1804,7 @@ ], 'ProductVariant_productvariant_41966393294899' => [ 'id' => 64593, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393294899', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393294899', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393294899', @@ -1859,7 +1859,7 @@ ], 'ProductVariant_productvariant_41966393327667' => [ 'id' => 64594, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393327667', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393327667', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393327667', @@ -1914,7 +1914,7 @@ ], 'ProductVariant_productvariant_41966393360435' => [ 'id' => 64595, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393360435', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393360435', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393360435', @@ -1969,7 +1969,7 @@ ], 'ProductVariant_productvariant_41966393393203' => [ 'id' => 64596, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393393203', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393393203', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393393203', @@ -2024,7 +2024,7 @@ ], 'ProductVariant_productvariant_41966393425971' => [ 'id' => 64597, - 'shopifyId' => 'gid://shopify/ProductVariant/41966393425971', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966393425971', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966393425971', @@ -2079,7 +2079,7 @@ ], 'MediaImage_mediaimage_23117955760179' => [ 'id' => 64675, - 'shopifyId' => 'gid://shopify/MediaImage/23117955760179', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955760179', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955760179', @@ -2102,7 +2102,7 @@ ], 'MediaImage_mediaimage_23117955825715' => [ 'id' => 64676, - 'shopifyId' => 'gid://shopify/MediaImage/23117955825715', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955825715', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955825715', @@ -2125,7 +2125,7 @@ ], 'MediaImage_mediaimage_23117955858483' => [ 'id' => 64677, - 'shopifyId' => 'gid://shopify/MediaImage/23117955858483', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955858483', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955858483', @@ -2148,7 +2148,7 @@ ], 'MediaImage_mediaimage_23117955891251' => [ 'id' => 64678, - 'shopifyId' => 'gid://shopify/MediaImage/23117955891251', + 'shopifyGid' => 'gid://shopify/MediaImage/23117955891251', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23117955891251', @@ -2171,7 +2171,7 @@ ], 'ProductVariant_productvariant_41966400143411' => [ 'id' => 64679, - 'shopifyId' => 'gid://shopify/ProductVariant/41966400143411', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966400143411', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966400143411', @@ -2226,7 +2226,7 @@ ], 'MediaImage_mediaimage_23118015758387' => [ 'id' => 65045, - 'shopifyId' => 'gid://shopify/MediaImage/23118015758387', + 'shopifyGid' => 'gid://shopify/MediaImage/23118015758387', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118015758387', @@ -2249,7 +2249,7 @@ ], 'ProductVariant_productvariant_41966466236467' => [ 'id' => 65046, - 'shopifyId' => 'gid://shopify/ProductVariant/41966466236467', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966466236467', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966466236467', @@ -2300,7 +2300,7 @@ ], 'MediaImage_mediaimage_23118019788851' => [ 'id' => 65081, - 'shopifyId' => 'gid://shopify/MediaImage/23118019788851', + 'shopifyGid' => 'gid://shopify/MediaImage/23118019788851', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118019788851', @@ -2323,7 +2323,7 @@ ], 'ProductVariant_productvariant_41966470201395' => [ 'id' => 65082, - 'shopifyId' => 'gid://shopify/ProductVariant/41966470201395', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966470201395', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966470201395', @@ -2374,7 +2374,7 @@ ], 'MediaImage_mediaimage_23118023426099' => [ 'id' => 65108, - 'shopifyId' => 'gid://shopify/MediaImage/23118023426099', + 'shopifyGid' => 'gid://shopify/MediaImage/23118023426099', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118023426099', @@ -2397,7 +2397,7 @@ ], 'ProductVariant_productvariant_41966471970867' => [ 'id' => 65109, - 'shopifyId' => 'gid://shopify/ProductVariant/41966471970867', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966471970867', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966471970867', @@ -2448,7 +2448,7 @@ ], 'MediaImage_mediaimage_23118091616307' => [ 'id' => 65621, - 'shopifyId' => 'gid://shopify/MediaImage/23118091616307', + 'shopifyGid' => 'gid://shopify/MediaImage/23118091616307', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118091616307', @@ -2471,7 +2471,7 @@ ], 'ProductVariant_productvariant_41966558183475' => [ 'id' => 65622, - 'shopifyId' => 'gid://shopify/ProductVariant/41966558183475', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966558183475', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966558183475', @@ -2522,7 +2522,7 @@ ], 'MediaImage_mediaimage_23118095417395' => [ 'id' => 65645, - 'shopifyId' => 'gid://shopify/MediaImage/23118095417395', + 'shopifyGid' => 'gid://shopify/MediaImage/23118095417395', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118095417395', @@ -2545,7 +2545,7 @@ ], 'ProductVariant_productvariant_41966567358515' => [ 'id' => 65646, - 'shopifyId' => 'gid://shopify/ProductVariant/41966567358515', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966567358515', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966567358515', @@ -2596,7 +2596,7 @@ ], 'MediaImage_mediaimage_23118112358451' => [ 'id' => 65774, - 'shopifyId' => 'gid://shopify/MediaImage/23118112358451', + 'shopifyGid' => 'gid://shopify/MediaImage/23118112358451', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118112358451', @@ -2619,7 +2619,7 @@ ], 'ProductVariant_productvariant_41966582956083' => [ 'id' => 65775, - 'shopifyId' => 'gid://shopify/ProductVariant/41966582956083', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966582956083', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966582956083', @@ -2670,7 +2670,7 @@ ], 'MediaImage_mediaimage_23118148141107' => [ 'id' => 66026, - 'shopifyId' => 'gid://shopify/MediaImage/23118148141107', + 'shopifyGid' => 'gid://shopify/MediaImage/23118148141107', 'type' => 'MediaImage', 'data' => [ 'id' => 'gid://shopify/MediaImage/23118148141107', @@ -2693,7 +2693,7 @@ ], 'ProductVariant_productvariant_41966614183987' => [ 'id' => 66027, - 'shopifyId' => 'gid://shopify/ProductVariant/41966614183987', + 'shopifyGid' => 'gid://shopify/ProductVariant/41966614183987', 'type' => 'ProductVariant', 'data' => [ 'id' => 'gid://shopify/ProductVariant/41966614183987', diff --git a/tests/unit/jobs/ProcessBulkOperationDataTest.php b/tests/unit/jobs/ProcessBulkOperationDataTest.php index 28820f10..1b9129c1 100644 --- a/tests/unit/jobs/ProcessBulkOperationDataTest.php +++ b/tests/unit/jobs/ProcessBulkOperationDataTest.php @@ -79,7 +79,7 @@ public function testProcessItemCreatesShopifyDataRecordForVariant(): void $job = $this->_makeJob(); $job->callProcessItem($json); - $record = ShopifyData::findOne(['shopifyId' => $variantGid, 'parentId' => $productGid]); + $record = ShopifyData::findOne(['shopifyGid' => $variantGid, 'parentId' => $productGid]); self::assertNotNull($record); self::assertEquals('ProductVariant', $record->type); self::assertEquals($productGid, $record->parentId); @@ -107,7 +107,7 @@ public function testProcessItemUpdatesExistingShopifyDataRecord(): void ]); $job->callProcessItem($updatedJson); - $records = ShopifyData::find()->where(['shopifyId' => $variantGid, 'parentId' => $productGid])->all(); + $records = ShopifyData::find()->where(['shopifyGid' => $variantGid, 'parentId' => $productGid])->all(); // Should still only be one record (updated in place) self::assertCount(1, $records); } @@ -188,7 +188,7 @@ public function testProcessItemsFromRealBulkOperationJsonl(): void // createOrUpdateProduct requires a full element save — mock it out Plugin::getInstance()->set('products', $this->makeEmpty(Products::class, [ 'createOrUpdateProduct' => fn() => true, - 'deleteShopifyDataByShopifyId' => fn() => null, + 'deleteShopifyDataByShopifyGid' => fn() => null, ])); $job = $this->_makeJob(); @@ -207,7 +207,7 @@ public function testProcessItemsFromRealBulkOperationJsonl(): void self::assertEquals(60, $total); // Product row - $productRow = ShopifyData::find()->where(['shopifyId' => $productGid, 'type' => 'Product'])->one(); + $productRow = ShopifyData::find()->where(['shopifyGid' => $productGid, 'type' => 'Product'])->one(); self::assertNotNull($productRow); self::assertNull($productRow->parentId); @@ -245,7 +245,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void { // Insert a couple of rows that should be wiped \Yii::$app->db->createCommand()->insert(\craft\shopify\db\Table::DATA, [ - 'shopifyId' => 'gid://shopify/Product/before-clear-test-001', + 'shopifyGid' => 'gid://shopify/Product/before-clear-test-001', 'type' => 'Product', 'data' => json_encode(['id' => 'gid://shopify/Product/before-clear-test-001']), 'parentId' => null, @@ -256,7 +256,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void $service = Plugin::getInstance()->getBulkOperations(); $model = new BulkOperation(); - $model->shopifyId = self::BULK_OP_GID; + $model->shopifyGid = self::BULK_OP_GID; $model->query = 'query {}'; $model->clearData = 'none'; $model->setStatus(BulkOperationStatus::Created); @@ -271,7 +271,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void { \Yii::$app->db->createCommand()->insert(\craft\shopify\db\Table::DATA, [ - 'shopifyId' => 'gid://shopify/Product/before-none-test-001', + 'shopifyGid' => 'gid://shopify/Product/before-none-test-001', 'type' => 'Product', 'data' => json_encode(['id' => 'gid://shopify/Product/before-none-test-001']), 'parentId' => null, @@ -284,7 +284,7 @@ public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void $service = Plugin::getInstance()->getBulkOperations(); $model = new BulkOperation(); - $model->shopifyId = self::BULK_OP_GID; + $model->shopifyGid = self::BULK_OP_GID; $model->query = 'query {}'; $model->clearData = 'none'; $model->setStatus(BulkOperationStatus::Created); @@ -303,7 +303,7 @@ public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void private function _makeJob(string $clearData = 'none'): TestableProcessBulkOperationData { return new TestableProcessBulkOperationData([ - 'bulkOperationShopifyId' => self::BULK_OP_GID, + 'bulkOperationShopifyGid' => self::BULK_OP_GID, 'dataUrl' => 'https://storage.example.com/data.jsonl', 'objectCount' => 0, 'clearData' => $clearData, @@ -324,7 +324,7 @@ public function callProcessItem(mixed $item): void public function callBefore(): void { // Call only our override, not BaseBatchedJob::before() which requires queue context - $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId); + $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid); if (!$bulkOperation) { return; @@ -336,7 +336,7 @@ public function callBefore(): void if ($this->clearData === \craft\shopify\records\BulkOperation::CLEAR_DATA_ALL) { ShopifyData::deleteAll(); } elseif ($this->clearData !== \craft\shopify\records\BulkOperation::CLEAR_DATA_NONE) { - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->clearData); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->clearData); } } } diff --git a/tests/unit/services/BulkOperationsTest.php b/tests/unit/services/BulkOperationsTest.php index 5f3c4f86..d1c106e0 100644 --- a/tests/unit/services/BulkOperationsTest.php +++ b/tests/unit/services/BulkOperationsTest.php @@ -55,28 +55,28 @@ public function testGetAllBulkOperationsReturnsCollection(): void public function testGetAllBulkOperationsContainsFixtureData(): void { $ops = Plugin::getInstance()->getBulkOperations()->getAllBulkOperations(); - $shopifyIds = $ops->pluck('shopifyId')->all(); + $shopifyIds = $ops->pluck('shopifyGid')->all(); self::assertContains(self::FIXTURE_GID, $shopifyIds); } // ------------------------------------------------------------------------- - // getBulkOperationByShopifyId + // getBulkOperationByShopifyGid // ------------------------------------------------------------------------- - public function testGetBulkOperationByShopifyIdFindsKnownRecord(): void + public function testGetBulkOperationByShopifyGidFindsKnownRecord(): void { - $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId(self::FIXTURE_GID); + $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid(self::FIXTURE_GID); self::assertNotNull($op); self::assertInstanceOf(BulkOperation::class, $op); - self::assertEquals(self::FIXTURE_GID, $op->shopifyId); + self::assertEquals(self::FIXTURE_GID, $op->shopifyGid); self::assertEquals(BulkOperationStatus::Completed, $op->getStatus()); } - public function testGetBulkOperationByShopifyIdReturnsNullForUnknownId(): void + public function testGetBulkOperationByShopifyGidReturnsNullForUnknownId(): void { - $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId('gid://shopify/BulkOperation/does-not-exist'); + $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/does-not-exist'); self::assertNull($op); } @@ -98,12 +98,12 @@ public function testSaveBulkOperationCreatesNewRecord(): void public function testSaveBulkOperationUpdatesExistingRecord(): void { $service = Plugin::getInstance()->getBulkOperations(); - $op = $service->getBulkOperationByShopifyId(self::FIXTURE_GID); + $op = $service->getBulkOperationByShopifyGid(self::FIXTURE_GID); $op->objectCount = 9999; $service->saveBulkOperation($op, false); - $reloaded = $service->getBulkOperationByShopifyId(self::FIXTURE_GID); + $reloaded = $service->getBulkOperationByShopifyGid(self::FIXTURE_GID); self::assertEquals(9999, $reloaded->objectCount); } @@ -121,7 +121,7 @@ public function testDeleteBulkOperationByIdRemovesRecord(): void $result = $service->deleteBulkOperationById($id); self::assertTrue($result); - self::assertNull($service->getBulkOperationByShopifyId('gid://shopify/BulkOperation/delete-test-001')); + self::assertNull($service->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/delete-test-001')); } public function testDeleteBulkOperationByIdReturnsTrueForMissingRecord(): void @@ -140,7 +140,7 @@ public function testCannotDeleteProcessingBulkOperation(): void $result = $service->deleteBulkOperationById($model->id); self::assertFalse($result); - self::assertNotNull($service->getBulkOperationByShopifyId('gid://shopify/BulkOperation/processing-test-001')); + self::assertNotNull($service->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/processing-test-001')); } // ------------------------------------------------------------------------- @@ -180,7 +180,7 @@ public function testHandleBulkOperationFinishedMarksCanceledAsCompleted(): void $service->handleBulkOperationFinished(['admin_graphql_api_id' => $gid]); - $reloaded = $service->getBulkOperationByShopifyId($gid); + $reloaded = $service->getBulkOperationByShopifyGid($gid); self::assertNotNull($reloaded); self::assertEquals(BulkOperationStatus::Completed, $reloaded->getStatus()); self::assertEquals('CANCELED', $reloaded->shopifyStatus); @@ -207,7 +207,7 @@ public function testHandleBulkOperationFinishedStoresUrlAndObjectCountWhenComple $service->handleBulkOperationFinished(['admin_graphql_api_id' => $gid]); - $reloaded = $service->getBulkOperationByShopifyId($gid); + $reloaded = $service->getBulkOperationByShopifyGid($gid); self::assertNotNull($reloaded); self::assertEquals('COMPLETED', $reloaded->shopifyStatus); self::assertEquals($dataUrl, $reloaded->url); @@ -241,19 +241,19 @@ public function testQueueNextBulkOperationReturnsFalseWhenAlreadyProcessing(): v // Helpers // ------------------------------------------------------------------------- - private function _makeQueuedOp(string $shopifyId): BulkOperation + private function _makeQueuedOp(string $gid): BulkOperation { $model = new BulkOperation(); - $model->shopifyId = $shopifyId; + $model->shopifyGid = $gid; $model->query = 'query { products { edges { node { id } } } }'; $model->clearData = 'none'; $model->setStatus(BulkOperationStatus::Queued); return $model; } - private function _makeCreatedOp(string $shopifyId): BulkOperation + private function _makeCreatedOp(string $gid): BulkOperation { - $model = $this->_makeQueuedOp($shopifyId); + $model = $this->_makeQueuedOp($gid); $model->setStatus(BulkOperationStatus::Created); return $model; } diff --git a/tests/unit/services/ProductsTest.php b/tests/unit/services/ProductsTest.php index bceb97a8..b3f16427 100644 --- a/tests/unit/services/ProductsTest.php +++ b/tests/unit/services/ProductsTest.php @@ -63,13 +63,13 @@ public function testNormalizeShopifyGidDoesNotDoublePrefix(): void } // ------------------------------------------------------------------------- - // deleteShopifyDataByShopifyId + // deleteShopifyDataByShopifyGid // ------------------------------------------------------------------------- - public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): void + public function testDeleteShopifyDataByShopifyGidRemovesProductAndChildren(): void { // Verify fixture data exists before deletion - $productRow = ShopifyData::find()->where(['shopifyId' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); self::assertNotNull($productRow, 'Fixture product row must exist before deletion test.'); $variantsBefore = ShopifyData::find() @@ -77,10 +77,10 @@ public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): voi ->count(); self::assertGreaterThan(0, $variantsBefore, 'Fixture must have at least one variant.'); - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId(self::PRODUCT_GID); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid(self::PRODUCT_GID); // Product row should be gone - $productRow = ShopifyData::find()->where(['shopifyId' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); self::assertNull($productRow); // All direct children (variants, images) should also be gone @@ -88,10 +88,10 @@ public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): voi self::assertEquals(0, $children); } - public function testDeleteShopifyDataByShopifyIdAcceptsNumericId(): void + public function testDeleteShopifyDataByShopifyGidAcceptsNumericId(): void { // Numeric ID should be normalized to GID before lookup — no exception expected - Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId('7136060145715'); + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid('7136060145715'); $this->assertTrue(true); } @@ -164,7 +164,7 @@ public function testEagerLoadMetafieldsForProductsMapsKeyValuePairs(): void // Insert a product row and a metafield child \Yii::$app->db->createCommand()->insert(Table::DATA, [ - 'shopifyId' => $productGid, + 'shopifyGid' => $productGid, 'type' => 'Product', 'data' => json_encode(['id' => $productGid, 'title' => 'Test']), 'parentId' => null, @@ -174,7 +174,7 @@ public function testEagerLoadMetafieldsForProductsMapsKeyValuePairs(): void ])->execute(); \Yii::$app->db->createCommand()->insert(Table::DATA, [ - 'shopifyId' => 'gid://shopify/Metafield/test-mf-1', + 'shopifyGid' => 'gid://shopify/Metafield/test-mf-1', 'type' => 'Metafield', 'data' => json_encode(['id' => 'gid://shopify/Metafield/test-mf-1', 'key' => 'my_key', 'value' => 'my_value']), 'parentId' => $productGid, From 42d0c30d08c6ab6c6be2dca7c6fbe8f841d1d439 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 18 Jun 2026 09:09:14 +0100 Subject: [PATCH 09/65] WIP PW tests --- package-lock.json | 27 +++++++++++++++ package.json | 1 + tests-playwright/.env.example | 23 +++++++++++++ tests-playwright/.gitignore | 2 ++ .../ddev-config/config.local.yaml | 3 ++ tests-playwright/index.js | 14 ++++++++ .../tests/navigation/index.test.js | 33 +++++++++++++++++++ 7 files changed, 103 insertions(+) create mode 100644 tests-playwright/.env.example create mode 100644 tests-playwright/.gitignore create mode 100644 tests-playwright/ddev-config/config.local.yaml create mode 100644 tests-playwright/index.js create mode 100644 tests-playwright/tests/navigation/index.test.js diff --git a/package-lock.json b/package-lock.json index 72cbe5de..245a46f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "@craftcms/shopify", "devDependencies": { + "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", @@ -13,6 +14,20 @@ "prettier": "^2.7.1" } }, + "../cms/packages/craftcms-playwright": { + "name": "@craftcms/playwright", + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@playwright/test": "^1.47.0", + "events": "^3.3.0", + "signale": "^1.4.0" + }, + "bin": { + "craft-playwright": "src/cli.js" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -1835,6 +1850,10 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, + "node_modules/@craftcms/playwright": { + "resolved": "../cms/packages/craftcms-playwright", + "link": true + }, "node_modules/@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", @@ -12329,6 +12348,14 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, + "@craftcms/playwright": { + "version": "file:../cms/packages/craftcms-playwright", + "requires": { + "@playwright/test": "^1.47.0", + "events": "^3.3.0", + "signale": "^1.4.0" + } + }, "@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", diff --git a/package.json b/package.json index 4cd823e7..b050a33b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "extends @craftcms/browserslist-config" ], "devDependencies": { + "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", diff --git a/tests-playwright/.env.example b/tests-playwright/.env.example new file mode 100644 index 00000000..38220d16 --- /dev/null +++ b/tests-playwright/.env.example @@ -0,0 +1,23 @@ +CRAFT_APP_ID=craftcms +CRAFT_ENVIRONMENT=dev +CRAFT_SECURITY_KEY=qwerty1234567890 +CRAFT_CP_TRIGGER=admin + +PRIMARY_SITE_URL=https://playwright.ddev.site +AUTH_USERNAME=admin +AUTH_PASSWORD=NewPassword +REPO_PATH=../../../. + +# the actual namespace of the *Fixture.php files +CODECEPTION_FIXTURES_NAMESPACE='crafttests\fixtures' +# the location of the *Fixture.php files relative to the root of your repo +CODECEPTION_FIXTURES_PATH=tests/fixtures + +CRAFT_DB_DRIVER="mysql" +CRAFT_DB_SERVER="db" +CRAFT_DB_PORT="3306" +CRAFT_DB_DATABASE="db" +CRAFT_DB_USER="db" +CRAFT_DB_PASSWORD="db" +CRAFT_DB_SCHEMA="public" +CRAFT_DB_TABLE_PREFIX="" \ No newline at end of file diff --git a/tests-playwright/.gitignore b/tests-playwright/.gitignore new file mode 100644 index 00000000..fa04e32d --- /dev/null +++ b/tests-playwright/.gitignore @@ -0,0 +1,2 @@ +/.env +/.authentication.json \ No newline at end of file diff --git a/tests-playwright/ddev-config/config.local.yaml b/tests-playwright/ddev-config/config.local.yaml new file mode 100644 index 00000000..6fc1568d --- /dev/null +++ b/tests-playwright/ddev-config/config.local.yaml @@ -0,0 +1,3 @@ +host_db_port: '33069' +# if you wish to rename your test project from the default "playwright", +# you need to both add a name to this file and specify a matching value under PRIMARY_SITE_URL in the .env file diff --git a/tests-playwright/index.js b/tests-playwright/index.js new file mode 100644 index 00000000..909af02a --- /dev/null +++ b/tests-playwright/index.js @@ -0,0 +1,14 @@ +/* jshint esversion: 9, strict: false */ +/* globals module, require */ +const craftPlaywright = require('@craftcms/playwright'); + +craftPlaywright.test = craftPlaywright.test.extend({ + // Here there is the ability to extend the test object +}); + +// You can listen to events here +// craftPlaywright.events.cleanAll.on('before', async () => { +// process.stdout.write('--- Before Clean All --- \n'); +// }); + +module.exports = craftPlaywright; diff --git a/tests-playwright/tests/navigation/index.test.js b/tests-playwright/tests/navigation/index.test.js new file mode 100644 index 00000000..4aacdd8e --- /dev/null +++ b/tests-playwright/tests/navigation/index.test.js @@ -0,0 +1,33 @@ +/* jshint esversion: 9, strict: false */ +/* globals module, require */ +const {test, expect} = require('../../index'); + +test.beforeEach(async ({craftDashboard}) => { + await craftDashboard.goTo(); +}); + +test.describe('Navigation', () => { + const navItems = [ + ['Shopify', 'Settings'], + ]; + + test('Global navigation has expected links', async ({page}) => { + await expect(page.locator('#global-sidebar nav ul li a')).toContainText( + navItems.map((item) => (Array.isArray(item) ? item[0] : item)) + ); + }); + + test('Navigation items go to the correct pages', async ({ + craftDashboard, + page, + }) => { + for (let i = 0; i < navItems.length; i++) { + await craftDashboard.goTo(); + let text = Array.isArray(navItems[i]) ? navItems[i][0] : navItems[i]; + let title = Array.isArray(navItems[i]) ? navItems[i][1] : text; + + await page.click('#global-sidebar nav ul li a:has-text("' + text + '")'); + await expect(page.locator('h1')).toContainText(title); + } + }); +}); From 47798e98d81c3c6451aaee8fec76d595df0c5625 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 18 Jun 2026 09:10:21 +0100 Subject: [PATCH 10/65] Update workflows for 8.0 --- .github/workflows/ci.yml | 3 ++- CHANGELOG-WIP.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG-WIP.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aedb60c3..c5bf8a6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,8 @@ on: workflow_dispatch: push: branches: - - '7.x' + - '8.x' + - '8.0' pull_request: permissions: contents: read diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000..625ecbe6 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1 @@ +# WIP Release Notes for Shopify 8.0 \ No newline at end of file From a595227e7a00fe2253c0d71d4268b1a2373d9f90 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 18 Jun 2026 09:32:40 +0100 Subject: [PATCH 11/65] Changelog WIP --- CHANGELOG-WIP.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 625ecbe6..6b1add5f 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1 +1,22 @@ -# WIP Release Notes for Shopify 8.0 \ No newline at end of file +# WIP Release Notes for Shopify 8.0 + +> [!IMPORTANT] +> If you change the **Additional Features** or **Custom Scopes** settings after the app is already authorized, you must update the scopes in your Shopify app configuration and then re-authorize the app. + +- Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215)) +- It’s now possible to view the required API scopes in the plugin settings. +- It’s now possible to extend the API scopes with opt-in additional features and custom scopes. +- It’s now possible to customize the Shopify API context before and after initialization via new events. +- Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. +- Added `craft\shopify\events\DefineInitializeApiContextEvent`. +- Added `craft\shopify\models\Settings::REQUIRED_SCOPES`. +- Added `craft\shopify\models\Settings::getAdditionalFeatures()`. +- Added `craft\shopify\models\Settings::getAdditionalFeaturesOptions()`. +- Added `craft\shopify\models\Settings::getCustomScopes()`. +- Added `craft\shopify\models\Settings::getScopes()`. +- Added `craft\shopify\models\Settings::setAdditionalFeatures()`. +- Added `craft\shopify\models\Settings::setCustomScopes()`. +- Added `craft\shopify\services\Api::EVENT_AFTER_INITIALIZE_API_CONTEXT`. +- Added `craft\shopify\services\Api::EVENT_DEFINE_INITIALIZE_API_CONTEXT`. +- Added `craft\shopify\services\Api::getShopLocalesGql()`. +- Fixed a bug where validation errors for the "Context Pricing Countries" setting weren't displaying correctly. \ No newline at end of file From 71fc700feb7cb6ba647a7f4a7d50429b5a7e47cf Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 19 Jun 2026 08:03:11 +0100 Subject: [PATCH 12/65] Remove Craft 4 support --- .github/workflows/craft4-ci.yml | 78 ---------- CHANGELOG-WIP.md | 4 +- README.md | 2 +- composer.json | 4 +- composer.lock | 136 ++++++++++++------ phpstan.craft4.neon | 26 ---- phpstan.neon | 5 - src/controllers/AuthController.php | 17 +-- src/controllers/SettingsController.php | 18 +-- src/controllers/WebhooksController.php | 13 +- src/elements/Product.php | 36 +---- .../conditions/products/ProductCondition.php | 15 -- src/enums/BulkOperationStatus.php | 11 -- src/fieldlayoutelements/MetafieldsField.php | 24 +--- src/fieldlayoutelements/OptionsField.php | 24 +--- src/fieldlayoutelements/VariantsField.php | 24 +--- src/helpers/Product.php | 10 -- src/utilities/Sync.php | 9 -- 18 files changed, 120 insertions(+), 336 deletions(-) delete mode 100644 .github/workflows/craft4-ci.yml delete mode 100644 phpstan.craft4.neon diff --git a/.github/workflows/craft4-ci.yml b/.github/workflows/craft4-ci.yml deleted file mode 100644 index f1f0bf41..00000000 --- a/.github/workflows/craft4-ci.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: craft4-ci -on: - workflow_dispatch: - push: - branches: - - '7.x' - pull_request: -permissions: - contents: read -concurrency: - group: craft4-ci-${{ github.ref }} - cancel-in-progress: true -jobs: - code-quality: - name: code-quality (Craft 4) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - tools: composer:2.7 - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - - name: Install Craft 4 dependencies - run: composer update --no-interaction --no-audit --no-progress --prefer-dist --with craftcms/cms:^4 --with-all-dependencies - - name: Run ECS - run: composer check-cs - - name: Run PHPStan - run: vendor/bin/phpstan analyse --configuration=phpstan.craft4.neon --memory-limit=1G - - name: Install Node dependencies - run: npm ci - - name: Run Prettier - run: npm run check-prettier - - tests: - name: tests (Craft 4, ${{ matrix.db }}) - runs-on: ubuntu-latest - strategy: - matrix: - db: [mysql, pgsql] - include: - - db: mysql - env_file: tests/.env.example.mysql - - db: pgsql - env_file: tests/.env.example.pgsql - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ALLOW_EMPTY_PASSWORD: yes - MYSQL_DATABASE: craft_test - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - postgres: - image: postgres:16 - env: - POSTGRES_USER: root - POSTGRES_DB: craft_test - POSTGRES_HOST_AUTH_METHOD: trust - ports: - - 5432:5432 - options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - tools: composer:2.7 - - name: Install Craft 4 dependencies - run: composer update --no-interaction --no-audit --no-progress --prefer-dist --with craftcms/cms:^4 --with-all-dependencies - - name: Create test environment - run: cp ${{ matrix.env_file }} tests/.env - - name: Run Tests - run: composer run testunit diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 625ecbe6..d2c57ff7 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1 +1,3 @@ -# WIP Release Notes for Shopify 8.0 \ No newline at end of file +# WIP Release Notes for Shopify 8.0 + +- Shopify for Craft now requires Craft CMS 5.10.7 or later. \ No newline at end of file diff --git a/README.md b/README.md index 6a8456bd..ba313ba7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com ## Installation -Shopify requires Craft CMS 4.15.0+ or 5.0.0+. +Shopify requires Craft CMS 5.10.7+. To install the plugin, visit the [Plugin Store](https://plugins.craftcms.com/shopify) from your Craft project, or follow these instructions. diff --git a/composer.json b/composer.json index e84111da..4e6cd3f2 100644 --- a/composer.json +++ b/composer.json @@ -22,14 +22,14 @@ "require": { "php": "^8.2", "carnage/php-graphql-client": "^1.14", - "craftcms/cms": "^5.0.0-beta.10||^4.15.0", + "craftcms/cms": "^5.10.7", "shopify/shopify-api": "^6.0.0" }, "require-dev": { "codeception/codeception": "^5.0.11", "codeception/module-asserts": "^3.0.0", "codeception/module-yii2": "^1.1.9", - "craftcms/feed-me": "^6.6.1||^5.9.0", + "craftcms/feed-me": "^6.6.1", "craftcms/ecs": "dev-main", "craftcms/phpstan": "dev-main", "craftcms/rector": "dev-main", diff --git a/composer.lock b/composer.lock index 83825d27..5ca2dbe1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4c4bd3bcadf528b01b98cf14d2514119", + "content-hash": "1e11372d30c0d8dfb70aa25e285717a4", "packages": [ { "name": "bacon/bacon-qr-code", @@ -63,16 +63,16 @@ }, { "name": "brick/math", - "version": "0.17.0", + "version": "0.17.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee" + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", - "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", + "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "shasum": "" }, "require": { @@ -110,7 +110,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.0" + "source": "https://github.com/brick/math/tree/0.17.2" }, "funding": [ { @@ -118,7 +118,7 @@ "type": "github" } ], - "time": "2026-03-17T12:54:54+00:00" + "time": "2026-05-25T20:34:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -538,16 +538,16 @@ }, { "name": "craftcms/cms", - "version": "5.10.5", + "version": "5.10.7", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "f781aecea946641c1c66baa5cf4a39872f99e7c6" + "reference": "6a08356668f772f0a4d79769d3de83508cd4e21c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/f781aecea946641c1c66baa5cf4a39872f99e7c6", - "reference": "f781aecea946641c1c66baa5cf4a39872f99e7c6", + "url": "https://api.github.com/repos/craftcms/cms/zipball/6a08356668f772f0a4d79769d3de83508cd4e21c", + "reference": "6a08356668f772f0a4d79769d3de83508cd4e21c", "shasum": "" }, "require": { @@ -556,6 +556,7 @@ "composer/semver": "^3.3.2", "craftcms/plugin-installer": "~1.6.0", "craftcms/server-check": "~5.1.0", + "craftcms/url-validator": "^1.0", "creocoder/yii2-nested-sets": "~0.9.0", "elvanto/litemoji": "~4.3.0", "enshrined/svg-sanitize": "~0.22.0", @@ -663,7 +664,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-06-02T18:34:00+00:00" + "time": "2026-06-18T01:14:32+00:00" }, { "name": "craftcms/plugin-installer", @@ -760,6 +761,62 @@ }, "time": "2026-04-07T16:48:35+00:00" }, + { + "name": "craftcms/url-validator", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/craftcms/url-validator.git", + "reference": "75b44bc4d3f89feb9410b85d385f01210edd5eb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/url-validator/zipball/75b44bc4d3f89feb9410b85d385f01210edd5eb1", + "reference": "75b44bc4d3f89feb9410b85d385f01210edd5eb1", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.0.2" + }, + "require-dev": { + "laravel/pint": "^1.29", + "nunomaduro/collision": "^8.1", + "pestphp/pest": "^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CraftCms\\UrlValidator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "Validate URLs and IP addresses against SSRF, DNS rebinding, and cloud-metadata attacks.", + "homepage": "https://github.com/craftcms/url-validator", + "keywords": [ + "IP", + "craftcms", + "security", + "ssrf", + "url", + "validation" + ], + "support": { + "issues": "https://github.com/craftcms/url-validator/issues", + "source": "https://github.com/craftcms/url-validator/tree/1.0.0" + }, + "time": "2026-06-15T17:29:09+00:00" + }, { "name": "creocoder/yii2-nested-sets", "version": "0.9.0", @@ -1439,22 +1496,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.11.1", + "version": "7.12.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" + "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", + "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.11", + "guzzlehttp/psr7": "^2.12.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1467,7 +1524,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5", + "guzzlehttp/test-server": "^0.5.1", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1547,7 +1604,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.11.1" + "source": "https://github.com/guzzle/guzzle/tree/7.12.1" }, "funding": [ { @@ -1563,7 +1620,7 @@ "type": "tidelift" } ], - "time": "2026-06-07T22:54:06+00:00" + "time": "2026-06-18T14:12:49+00:00" }, { "name": "guzzlehttp/promises", @@ -1651,16 +1708,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.0", + "version": "2.12.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", "shasum": "" }, "require": { @@ -1750,7 +1807,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" + "source": "https://github.com/guzzle/psr7/tree/2.12.1" }, "funding": [ { @@ -1766,7 +1823,7 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:30:48+00:00" + "time": "2026-06-18T09:49:37+00:00" }, { "name": "illuminate/collections", @@ -4060,20 +4117,20 @@ }, { "name": "ramsey/uuid", - "version": "4.x-dev", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1a1f98b037d664d9093a620cfa85305c7e50b244" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1a1f98b037d664d9093a620cfa85305c7e50b244", - "reference": "1a1f98b037d664d9093a620cfa85305c7e50b244", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.17", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4106,7 +4163,6 @@ "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." }, - "default-branch": true, "type": "library", "extra": { "captainhook": { @@ -4133,9 +4189,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.x" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2026-04-27T22:11:13+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "samdark/yii2-psr-log-target", @@ -7523,16 +7579,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -7583,9 +7639,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { "name": "yiisoft/yii2", diff --git a/phpstan.craft4.neon b/phpstan.craft4.neon deleted file mode 100644 index 030a133f..00000000 --- a/phpstan.craft4.neon +++ /dev/null @@ -1,26 +0,0 @@ -includes: - - phpstan.neon - -parameters: - # This config is only for the Craft 4 CI run. - # These ignores cover Craft 4 compatibility shims where PHPStan analyzes against - # a single runtime API surface and can report false positives for Craft 5-only APIs. - reportUnmatchedIgnoredErrors: false - excludePaths: - analyse: - - src/linktypes/Product.php - ignoreErrors: - - - message: '#Call to an undefined static method craft\\base\\Element::attributeHtml\(\)\.#' - path: src/elements/Product.php - - - message: '#Call to an undefined method craft\\web\\Response::noticeHtml\(\)\.#' - path: src/controllers/SettingsController.php - - - message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::conditionRuleTypes\(\)\.#' - path: src/elements/conditions/products/ProductCondition.php - - - message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::selectableConditionRules\(\)\.#' - path: src/elements/conditions/products/ProductCondition.php - - diff --git a/phpstan.neon b/phpstan.neon index b5576300..44554e4f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,8 +5,3 @@ parameters: level: 5 paths: - src - # Remove this ignore once Craft 4 support is dropped. - ignoreErrors: - - - message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::conditionRuleTypes\(\)\.#' - path: src/elements/conditions/products/ProductCondition.php diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index a0bb5fbc..1c1833af 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -58,7 +58,7 @@ public function actionIndex(): YiiResponse $validHmac = Utils::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret()); if (!$validHmac) { $html = $this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), Craft::t('shopify', 'Invalid or missing HMAC. Please try re-installing the app.')); - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } // If a code is present, it means the user has been redirected back from Shopify after authorizing the app. @@ -93,13 +93,13 @@ public function actionIndex(): YiiResponse Html::endTag('div') ; - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } catch (\Exception $e) { Craft::error($e->getMessage(), __METHOD__); $html = $this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), $e->getMessage()); - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } } @@ -118,18 +118,9 @@ public function actionIndex(): YiiResponse Html::endTag('div') . Html::endTag('div'); - return $this->_screenContent($screen, $html); + return $screen->contentHtml($html); } - /** - * Render CP screen content across Craft 4/5. - * @TODO remove when the plugin no longer supports Craft 4 - */ - private function _screenContent(Response $screen, string $html): YiiResponse - { - $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml'; - return $screen->{$method}($html); - } /** * @param array $cookies diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 59e2d59e..95c9b388 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -16,7 +16,6 @@ use craft\shopify\models\Settings; use craft\shopify\Plugin; use craft\web\Controller; -use craft\web\Response as CraftResponse; use yii\web\Response; /** @@ -193,23 +192,10 @@ public function actionIndex(?Settings $settings = null): Response $screen->action('shopify/settings/save-settings') ->redirectUrl('shopify/settings'); } else { - // @TODO remove when the plugin no longer support Craft 4 - if ($screen->hasMethod('noticeHtml') && method_exists(Cp::class, 'readOnlyNoticeHtml')) { - $screen->noticeHtml(Cp::readOnlyNoticeHtml()); - } + $screen->noticeHtml(Cp::readOnlyNoticeHtml()); } - return $this->_screenContent($screen, $html); - } - - /** - * Render CP screen content across Craft 4/5. - * @TODO remove when the plugin no longer supports Craft 4 - */ - private function _screenContent(CraftResponse $screen, string $html): Response - { - $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml'; - return $screen->{$method}($html); + return $screen->contentHtml($html); } /** diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index f7bb5df0..d29b1c0a 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -11,7 +11,6 @@ use craft\helpers\Html; use craft\shopify\Plugin; use craft\web\Controller; -use craft\web\Response as CraftResponse; use GraphQL\Query; use GraphQL\Variable; use Shopify\Exception\ShopifyException; @@ -164,17 +163,7 @@ public function actionEdit(): YiiResponse ->title(Craft::t('shopify', 'Webhooks')) ->selectedSubnavItem('webhooks'); - return $this->_screenContent($screen, $html); - } - - /** - * Render CP screen content across Craft 4/5 - * @TODO remove when the plugin no longer supports Craft 4 - */ - private function _screenContent(CraftResponse $screen, string $html): YiiResponse - { - $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml'; - return $screen->{$method}($html); + return $screen->contentHtml($html); } /** diff --git a/src/elements/Product.php b/src/elements/Product.php index 6c155700..71a2299e 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -731,7 +731,7 @@ public function getSidebarHtml(bool $static): string // Conditionally show metadata in the sidebar dependent on the field layout $excludeKeys = []; $fieldLayout = $this->getFieldLayout(); - $checkField = function($field) use (&$excludeKeys) { + $fieldLayout->getFields(function($field) use (&$excludeKeys) { if ($field instanceof VariantsField) { $excludeKeys[] = 'Variants'; return true; @@ -743,16 +743,7 @@ public function getSidebarHtml(bool $static): string return true; } return false; - }; - - // @TODO remove when the plugin no longer supports Craft 4 - if (!method_exists($fieldLayout, 'getFields')) { - foreach ($fieldLayout->getCustomFields() as $field) { - $checkField($field); - } - } else { - $fieldLayout->getFields($checkField); - } + }); return ProductHelper::renderCardHtml($this, $excludeKeys) . parent::getSidebarHtml($static); } @@ -892,29 +883,6 @@ protected static function defineSortOptions(): array return $sortOptions; } - /** - * @param string $attribute - * @return string - * @throws InvalidConfigException - * @TODO remove this method when support for Craft 4 is dropped - */ - protected function tableAttributeHtml(string $attribute): string - { - if (!in_array($attribute, [ - 'shopifyEdit', - 'shopifyStatus', - 'shopifyId', - 'options', - 'tags', - 'variants', - ])) { - /** @phpstan-ignore-next-line */ - return parent::tableAttributeHtml($attribute); - } - - return $this->attributeHtml($attribute); - } - /** * @param string $attribute * @return string diff --git a/src/elements/conditions/products/ProductCondition.php b/src/elements/conditions/products/ProductCondition.php index e1f00696..5144d412 100644 --- a/src/elements/conditions/products/ProductCondition.php +++ b/src/elements/conditions/products/ProductCondition.php @@ -17,21 +17,6 @@ */ class ProductCondition extends ElementCondition { - /** - * @inheritdoc - * @TODO remove this method when support for Craft 4 is dropped - */ - protected function conditionRuleTypes(): array - { - return array_merge(parent::conditionRuleTypes(), [ - ProductTypeConditionRule::class, - ShopifyStatusConditionRule::class, - VendorConditionRule::class, - HandleConditionRule::class, - TagsConditionRule::class, - ]); - } - /** * @inheritdoc */ diff --git a/src/enums/BulkOperationStatus.php b/src/enums/BulkOperationStatus.php index bfdc3540..f501dc2e 100644 --- a/src/enums/BulkOperationStatus.php +++ b/src/enums/BulkOperationStatus.php @@ -42,17 +42,6 @@ public function statusAsLabel(): string */ public function statusLabelHtml(): string { - // @TODO update this either when Craft 4 support is dropped or 4 gets enums - if (!class_exists(Color::class) || !method_exists(Cp::class, 'statusLabelHtml')) { - $color = match ($this) { - self::Created => 'blue', - self::Processing => 'yellow', - self::Completed => 'green', - default => 'gray', // takes care of draft - }; - return "" . $this->statusAsLabel(); - } - return Cp::statusLabelHtml([ 'color' => match ($this) { self::Queued => Color::Gray, diff --git a/src/fieldlayoutelements/MetafieldsField.php b/src/fieldlayoutelements/MetafieldsField.php index ade31b9c..d39c5e6a 100644 --- a/src/fieldlayoutelements/MetafieldsField.php +++ b/src/fieldlayoutelements/MetafieldsField.php @@ -74,30 +74,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa ]; } - // @TODO remove this when Craft 4 support is dropped - $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute(); - - $tableConfig = [ + return Cp::editableTableHtml([ 'id' => $this->id(), - 'name' => $name, + 'name' => $this->baseInputName(), 'cols' => $cols, 'rows' => $tableData, 'static' => true, - ]; - - return $this->_editableTableHtml($tableConfig); - } - - /** - * @TODO remove this when Craft 4 support is dropped - */ - private function _editableTableHtml(array $tableConfig): string - { - // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4) - if (!is_callable([Cp::class, 'editableTableHtml'])) { - return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig); - } - - return Cp::editableTableHtml($tableConfig); + ]); } } diff --git a/src/fieldlayoutelements/OptionsField.php b/src/fieldlayoutelements/OptionsField.php index 20b1a6cb..4640bc11 100644 --- a/src/fieldlayoutelements/OptionsField.php +++ b/src/fieldlayoutelements/OptionsField.php @@ -85,30 +85,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa } } - // @TODO remove this when Craft 4 support is dropped - $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute(); - - $tableConfig = [ + return Cp::editableTableHtml([ 'id' => $this->id(), - 'name' => $name, + 'name' => $this->baseInputName(), 'cols' => $cols, 'rows' => $tableData, 'static' => true, - ]; - - return $this->_editableTableHtml($tableConfig); - } - - /** - * @TODO remove this when Craft 4 support is dropped - */ - private function _editableTableHtml(array $tableConfig): string - { - // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4) - if (!is_callable([Cp::class, 'editableTableHtml'])) { - return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig); - } - - return Cp::editableTableHtml($tableConfig); + ]); } } diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php index cab9e0e1..46511a23 100644 --- a/src/fieldlayoutelements/VariantsField.php +++ b/src/fieldlayoutelements/VariantsField.php @@ -86,30 +86,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa Html::endTag('div'); } - // @TODO remove this when Craft 4 support is dropped - $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute(); - - $tableConfig = [ + return Cp::editableTableHtml([ 'id' => $this->id(), - 'name' => $name, + 'name' => $this->baseInputName(), 'cols' => $cols, 'rows' => $variantRows, 'static' => true, - ]; - - return $this->_editableTableHtml($tableConfig); - } - - /** - * @TODO remove this when Craft 4 support is dropped - */ - private function _editableTableHtml(array $tableConfig): string - { - // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4) - if (!is_callable([Cp::class, 'editableTableHtml'])) { - return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig); - } - - return Cp::editableTableHtml($tableConfig); + ]); } } diff --git a/src/helpers/Product.php b/src/helpers/Product.php index abef6ce9..063ed1d8 100644 --- a/src/helpers/Product.php +++ b/src/helpers/Product.php @@ -153,16 +153,6 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet */ public static function shopifyStatusHtml(ProductElement $product): string { - // @TODO update this either when Craft 4 support is dropped or 4 gets enums - if (!class_exists(Color::class) || !method_exists(Cp::class, 'statusLabelHtml')) { - $color = match (StringHelper::toLowerCase($product->shopifyStatus)) { - ProductElement::SHOPIFY_STATUS_ACTIVE => 'green', - ProductElement::SHOPIFY_STATUS_ARCHIVED => 'red', - default => 'orange', // takes care of draft - }; - return "" . StringHelper::titleize($product->shopifyStatus); - } - $color = match (StringHelper::toLowerCase($product->shopifyStatus)) { ProductElement::SHOPIFY_STATUS_ACTIVE => Color::Green->value, ProductElement::SHOPIFY_STATUS_ARCHIVED => Color::Red->value, diff --git a/src/utilities/Sync.php b/src/utilities/Sync.php index cf2996bc..bedccf78 100644 --- a/src/utilities/Sync.php +++ b/src/utilities/Sync.php @@ -39,15 +39,6 @@ public static function id(): string return 'shopify-sync'; } - /** - * @inheritdoc - * @TODO remove this method when support for Craft 4 is dropped - */ - public static function iconPath(): ?string - { - return self::icon(); // TODO: Change the autogenerated stub - } - /** * @inheritdoc */ From 947fd928f5f23c0056ff4f41ab914a72b07e0ba3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 13:52:34 +0100 Subject: [PATCH 13/65] bump deps --- composer.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.lock b/composer.lock index 5ca2dbe1..b7b8331d 100644 --- a/composer.lock +++ b/composer.lock @@ -538,16 +538,16 @@ }, { "name": "craftcms/cms", - "version": "5.10.7", + "version": "5.10.8", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "6a08356668f772f0a4d79769d3de83508cd4e21c" + "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/6a08356668f772f0a4d79769d3de83508cd4e21c", - "reference": "6a08356668f772f0a4d79769d3de83508cd4e21c", + "url": "https://api.github.com/repos/craftcms/cms/zipball/ec387a529a7f9a0c07f2a086c59dadc02fc79556", + "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556", "shasum": "" }, "require": { @@ -664,7 +664,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-06-18T01:14:32+00:00" + "time": "2026-06-23T11:02:47+00:00" }, { "name": "craftcms/plugin-installer", From c281062242db2cb1f63df4895410d59f798d0624 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 14:05:07 +0100 Subject: [PATCH 14/65] Revert "WIP PW tests" This reverts commit 42d0c30d08c6ab6c6be2dca7c6fbe8f841d1d439. --- package-lock.json | 27 --------------- package.json | 1 - tests-playwright/.env.example | 23 ------------- tests-playwright/.gitignore | 2 -- .../ddev-config/config.local.yaml | 3 -- tests-playwright/index.js | 14 -------- .../tests/navigation/index.test.js | 33 ------------------- 7 files changed, 103 deletions(-) delete mode 100644 tests-playwright/.env.example delete mode 100644 tests-playwright/.gitignore delete mode 100644 tests-playwright/ddev-config/config.local.yaml delete mode 100644 tests-playwright/index.js delete mode 100644 tests-playwright/tests/navigation/index.test.js diff --git a/package-lock.json b/package-lock.json index 245a46f7..72cbe5de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,6 @@ "": { "name": "@craftcms/shopify", "devDependencies": { - "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", @@ -14,20 +13,6 @@ "prettier": "^2.7.1" } }, - "../cms/packages/craftcms-playwright": { - "name": "@craftcms/playwright", - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@playwright/test": "^1.47.0", - "events": "^3.3.0", - "signale": "^1.4.0" - }, - "bin": { - "craft-playwright": "src/cli.js" - } - }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -1850,10 +1835,6 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, - "node_modules/@craftcms/playwright": { - "resolved": "../cms/packages/craftcms-playwright", - "link": true - }, "node_modules/@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", @@ -12348,14 +12329,6 @@ "integrity": "sha512-Yz/lREsfygFaU6VEjz+GBYFAUxrTXP3+PEfA0M0WVg/2hXqXitK83Qdk/OMgW/ByTlyvQlo6TsaTWgCm7ztU8Q==", "dev": true }, - "@craftcms/playwright": { - "version": "file:../cms/packages/craftcms-playwright", - "requires": { - "@playwright/test": "^1.47.0", - "events": "^3.3.0", - "signale": "^1.4.0" - } - }, "@craftcms/webpack": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@craftcms/webpack/-/webpack-1.1.2.tgz", diff --git a/package.json b/package.json index b050a33b..4cd823e7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "extends @craftcms/browserslist-config" ], "devDependencies": { - "@craftcms/playwright": "file:../cms/packages/craftcms-playwright", "@craftcms/webpack": "^1.1.0", "craftcms-sass": "^3.5.6", "husky": "^7.0.4", diff --git a/tests-playwright/.env.example b/tests-playwright/.env.example deleted file mode 100644 index 38220d16..00000000 --- a/tests-playwright/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -CRAFT_APP_ID=craftcms -CRAFT_ENVIRONMENT=dev -CRAFT_SECURITY_KEY=qwerty1234567890 -CRAFT_CP_TRIGGER=admin - -PRIMARY_SITE_URL=https://playwright.ddev.site -AUTH_USERNAME=admin -AUTH_PASSWORD=NewPassword -REPO_PATH=../../../. - -# the actual namespace of the *Fixture.php files -CODECEPTION_FIXTURES_NAMESPACE='crafttests\fixtures' -# the location of the *Fixture.php files relative to the root of your repo -CODECEPTION_FIXTURES_PATH=tests/fixtures - -CRAFT_DB_DRIVER="mysql" -CRAFT_DB_SERVER="db" -CRAFT_DB_PORT="3306" -CRAFT_DB_DATABASE="db" -CRAFT_DB_USER="db" -CRAFT_DB_PASSWORD="db" -CRAFT_DB_SCHEMA="public" -CRAFT_DB_TABLE_PREFIX="" \ No newline at end of file diff --git a/tests-playwright/.gitignore b/tests-playwright/.gitignore deleted file mode 100644 index fa04e32d..00000000 --- a/tests-playwright/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/.env -/.authentication.json \ No newline at end of file diff --git a/tests-playwright/ddev-config/config.local.yaml b/tests-playwright/ddev-config/config.local.yaml deleted file mode 100644 index 6fc1568d..00000000 --- a/tests-playwright/ddev-config/config.local.yaml +++ /dev/null @@ -1,3 +0,0 @@ -host_db_port: '33069' -# if you wish to rename your test project from the default "playwright", -# you need to both add a name to this file and specify a matching value under PRIMARY_SITE_URL in the .env file diff --git a/tests-playwright/index.js b/tests-playwright/index.js deleted file mode 100644 index 909af02a..00000000 --- a/tests-playwright/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/* jshint esversion: 9, strict: false */ -/* globals module, require */ -const craftPlaywright = require('@craftcms/playwright'); - -craftPlaywright.test = craftPlaywright.test.extend({ - // Here there is the ability to extend the test object -}); - -// You can listen to events here -// craftPlaywright.events.cleanAll.on('before', async () => { -// process.stdout.write('--- Before Clean All --- \n'); -// }); - -module.exports = craftPlaywright; diff --git a/tests-playwright/tests/navigation/index.test.js b/tests-playwright/tests/navigation/index.test.js deleted file mode 100644 index 4aacdd8e..00000000 --- a/tests-playwright/tests/navigation/index.test.js +++ /dev/null @@ -1,33 +0,0 @@ -/* jshint esversion: 9, strict: false */ -/* globals module, require */ -const {test, expect} = require('../../index'); - -test.beforeEach(async ({craftDashboard}) => { - await craftDashboard.goTo(); -}); - -test.describe('Navigation', () => { - const navItems = [ - ['Shopify', 'Settings'], - ]; - - test('Global navigation has expected links', async ({page}) => { - await expect(page.locator('#global-sidebar nav ul li a')).toContainText( - navItems.map((item) => (Array.isArray(item) ? item[0] : item)) - ); - }); - - test('Navigation items go to the correct pages', async ({ - craftDashboard, - page, - }) => { - for (let i = 0; i < navItems.length; i++) { - await craftDashboard.goTo(); - let text = Array.isArray(navItems[i]) ? navItems[i][0] : navItems[i]; - let title = Array.isArray(navItems[i]) ? navItems[i][1] : text; - - await page.click('#global-sidebar nav ul li a:has-text("' + text + '")'); - await expect(page.locator('h1')).toContainText(title); - } - }); -}); From 19b60ceeb35fafa5b62c8212e2bd1b8d5aedbe01 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 15:23:28 +0100 Subject: [PATCH 15/65] fix cs --- src/services/Products.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/Products.php b/src/services/Products.php index 9ad9af73..d103c875 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -13,7 +13,6 @@ use craft\helpers\StringHelper; use craft\models\FieldLayout; use craft\shopify\collections\VariantCollection; -use craft\shopify\db\Table; use craft\shopify\elements\Product; use craft\shopify\events\ShopifyProductSyncEvent; use craft\shopify\models\Variant; From 810561e79350cd003fabebeb2111da516d300a74 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 23 Jun 2026 17:14:26 +0100 Subject: [PATCH 16/65] More tests --- tests/unit/models/BulkOperationTest.php | 85 ++++++++++++++ tests/unit/models/VariantTest.php | 123 +++++++++++++++++++++ tests/unit/services/BulkOperationsTest.php | 16 +++ tests/unit/services/ProductsTest.php | 18 +++ 4 files changed, 242 insertions(+) create mode 100644 tests/unit/models/BulkOperationTest.php create mode 100644 tests/unit/models/VariantTest.php diff --git a/tests/unit/models/BulkOperationTest.php b/tests/unit/models/BulkOperationTest.php new file mode 100644 index 00000000..5cb24220 --- /dev/null +++ b/tests/unit/models/BulkOperationTest.php @@ -0,0 +1,85 @@ + ['class' => BulkOperationsFixture::class], + ]; + } + + private function _getFixtureOp(): BulkOperation + { + return Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid(self::FIXTURE_GID); + } + + // ------------------------------------------------------------------------- + // shopifyGid + // ------------------------------------------------------------------------- + + public function testShopifyGidIsString(): void + { + $op = $this->_getFixtureOp(); + self::assertIsString($op->shopifyGid); + } + + public function testShopifyGidHasCorrectFormat(): void + { + $op = $this->_getFixtureOp(); + self::assertStringStartsWith('gid://shopify/BulkOperation/', $op->shopifyGid); + } + + public function testShopifyGidMatchesFixture(): void + { + $op = $this->_getFixtureOp(); + self::assertEquals(self::FIXTURE_GID, $op->shopifyGid); + } + + public function testShopifyGidNumericSegmentIsNumeric(): void + { + $op = $this->_getFixtureOp(); + $lastSegment = substr($op->shopifyGid, strrpos($op->shopifyGid, '/') + 1); + self::assertMatchesRegularExpression('/^\d+$/', $lastSegment); + } + + // ------------------------------------------------------------------------- + // shopifyGid set on new model + // ------------------------------------------------------------------------- + + public function testShopifyGidCanBeSetOnNewModel(): void + { + $model = new BulkOperation(); + $model->shopifyGid = self::FIXTURE_GID; + + self::assertEquals(self::FIXTURE_GID, $model->shopifyGid); + self::assertIsString($model->shopifyGid); + } + + public function testShopifyGidIsNullByDefault(): void + { + $model = new BulkOperation(); + self::assertNull($model->shopifyGid); + } +} diff --git a/tests/unit/models/VariantTest.php b/tests/unit/models/VariantTest.php new file mode 100644 index 00000000..9002447c --- /dev/null +++ b/tests/unit/models/VariantTest.php @@ -0,0 +1,123 @@ + ['class' => ShopifyDataFixture::class], + ]; + } + + private function _getFirstVariant(): \craft\shopify\models\Variant + { + $products = $this->_makeMockProduct(self::PRODUCT_GID); + Plugin::getInstance()->getProducts()->eagerLoadVariantsForProducts([$products]); + return $products->variants->first(); + } + + // ------------------------------------------------------------------------- + // shopifyGid + // ------------------------------------------------------------------------- + + public function testShopifyGidIsString(): void + { + $variant = $this->_getFirstVariant(); + self::assertIsString($variant->shopifyGid); + } + + public function testShopifyGidHasCorrectFormat(): void + { + $variant = $this->_getFirstVariant(); + self::assertStringStartsWith('gid://shopify/ProductVariant/', $variant->shopifyGid); + } + + public function testShopifyGidMatchesFixture(): void + { + $variant = $this->_getFirstVariant(); + self::assertEquals(self::VARIANT_GID, $variant->shopifyGid); + } + + // ------------------------------------------------------------------------- + // shopifyId + // ------------------------------------------------------------------------- + + public function testShopifyIdIsString(): void + { + $variant = $this->_getFirstVariant(); + self::assertIsString($variant->shopifyId); + } + + public function testShopifyIdIsNumeric(): void + { + $variant = $this->_getFirstVariant(); + self::assertMatchesRegularExpression('/^\d+$/', $variant->shopifyId); + } + + public function testShopifyIdMatchesFixture(): void + { + $variant = $this->_getFirstVariant(); + self::assertEquals(self::VARIANT_ID, $variant->shopifyId); + } + + // ------------------------------------------------------------------------- + // shopifyId and shopifyGid relationship + // ------------------------------------------------------------------------- + + public function testShopifyIdIsLastSegmentOfGid(): void + { + $variant = $this->_getFirstVariant(); + $lastSegment = substr($variant->shopifyGid, strrpos($variant->shopifyGid, '/') + 1); + self::assertEquals($lastSegment, $variant->shopifyId); + } + + public function testShopifyGidContainsShopifyId(): void + { + $variant = $this->_getFirstVariant(); + self::assertStringContainsString($variant->shopifyId, $variant->shopifyGid); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function _makeMockProduct(string $gid): object + { + return new class($gid) { + public string $shopifyGid; + public ?VariantCollection $variants = null; + + public function __construct(string $gid) + { + $this->shopifyGid = $gid; + } + + public function setVariants(VariantCollection $variants): void + { + $this->variants = $variants; + } + }; + } +} diff --git a/tests/unit/services/BulkOperationsTest.php b/tests/unit/services/BulkOperationsTest.php index d1c106e0..7553249a 100644 --- a/tests/unit/services/BulkOperationsTest.php +++ b/tests/unit/services/BulkOperationsTest.php @@ -81,6 +81,22 @@ public function testGetBulkOperationByShopifyGidReturnsNullForUnknownId(): void self::assertNull($op); } + // ------------------------------------------------------------------------- + // getBulkOperationByShopifyId (deprecated) + // ------------------------------------------------------------------------- + + /** + * @deprecated in 8.0.0. Use [[testGetBulkOperationByShopifyGidFindsKnownRecord()]] instead. + */ + public function testGetBulkOperationByShopifyIdDelegatesToGidMethod(): void + { + $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId(self::FIXTURE_GID); + + self::assertNotNull($op); + self::assertInstanceOf(BulkOperation::class, $op); + self::assertEquals(self::FIXTURE_GID, $op->shopifyGid); + } + // ------------------------------------------------------------------------- // saveBulkOperation // ------------------------------------------------------------------------- diff --git a/tests/unit/services/ProductsTest.php b/tests/unit/services/ProductsTest.php index b3f16427..a9e36040 100644 --- a/tests/unit/services/ProductsTest.php +++ b/tests/unit/services/ProductsTest.php @@ -95,6 +95,24 @@ public function testDeleteShopifyDataByShopifyGidAcceptsNumericId(): void $this->assertTrue(true); } + // ------------------------------------------------------------------------- + // deleteShopifyDataByShopifyId (deprecated) + // ------------------------------------------------------------------------- + + /** + * @deprecated in 8.0.0. Use [[testDeleteShopifyDataByShopifyGidRemovesProductAndChildren()]] instead. + */ + public function testDeleteShopifyDataByShopifyIdDelegatesToGidMethod(): void + { + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + self::assertNotNull($productRow, 'Fixture product row must exist before deletion test.'); + + Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId(self::PRODUCT_GID); + + $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one(); + self::assertNull($productRow); + } + // ------------------------------------------------------------------------- // eagerLoadVariantsForProducts // ------------------------------------------------------------------------- From 33175e3dd017cde38b2c1f4f64d2a2696f46f9c8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 25 Jun 2026 07:32:31 +0100 Subject: [PATCH 17/65] Metafield normalization tidy --- src/elements/Product.php | 19 +- src/helpers/Metafield.php | 53 ++++ src/models/Variant.php | 24 +- src/services/Products.php | 31 +-- tests/unit/elements/ProductTest.php | 398 +++++++++++++++++++++++++++ tests/unit/services/ProductsTest.php | 3 +- 6 files changed, 474 insertions(+), 54 deletions(-) create mode 100644 src/helpers/Metafield.php create mode 100644 tests/unit/elements/ProductTest.php diff --git a/src/elements/Product.php b/src/elements/Product.php index d48d7ffb..7a467c20 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -23,6 +23,7 @@ use craft\shopify\fieldlayoutelements\MetafieldsField; use craft\shopify\fieldlayoutelements\OptionsField; use craft\shopify\fieldlayoutelements\VariantsField; +use craft\shopify\helpers\Metafield as MetafieldHelper; use craft\shopify\helpers\Product as ProductHelper; use craft\shopify\models\Variant; use craft\shopify\Plugin; @@ -359,8 +360,9 @@ public function getOptions(): array } /** - * @param string|array $value + * @param string|array $value A list-shaped array of `{key, value}` objects, or a JSON-encoded string of the same. * @return void + * @throws \InvalidArgumentException if the value is not a list-shaped array or JSON string of one. */ public function setMetafields(string|array $value): void { @@ -368,7 +370,11 @@ public function setMetafields(string|array $value): void $value = Json::decodeIfJson($value); } - $this->_metaFields = $value; + if (!is_array($value) || !array_is_list($value)) { + throw new \InvalidArgumentException('setMetafields() expects a list-shaped array of {key, value} objects or a JSON-encoded string of the same.'); + } + + $this->_metaFields = MetafieldHelper::normalizeToMap($value); } /** @@ -387,14 +393,7 @@ public function getMetafields(): array $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid); - $metafields = $data - ->mapWithKeys(function($d) { - return [ - $d['key'] => Json::decodeIfJson($d['value']), - ]; - }); - - $this->setMetafields($metafields); + $this->setMetafields($data->all()); return $this->_metaFields ?? []; } diff --git a/src/helpers/Metafield.php b/src/helpers/Metafield.php new file mode 100644 index 00000000..ba243427 --- /dev/null +++ b/src/helpers/Metafield.php @@ -0,0 +1,53 @@ + + * @since 8.0.0 + */ +class Metafield +{ + /** + * Normalizes an iterable of metafield data into a flat key => value map. + * + * Accepts either: + * - [[ShopifyData]] ActiveRecord objects (from [[Api::getShopifyDataByType()]] with `$returnRecords = true`), + * where each record's `data` column holds a JSON-encoded `{key, value}` object. + * - Pre-decoded associative arrays (from [[Api::getShopifyDataByType()]] without `$returnRecords`), + * where each item already has `key` and `value` keys. + * + * Rows that do not carry both `key` and `value` are silently skipped. + * + * @param iterable $rows + * @return array + */ + public static function normalizeToMap(iterable $rows): array + { + return collect($rows) + ->mapWithKeys(function($d) { + $data = match (true) { + $d instanceof ShopifyData => Json::decodeIfJson($d->data), + is_string($d) => Json::decodeIfJson($d), + default => $d, + }; + + if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) { + return []; + } + + return [$data['key'] => Json::decodeIfJson($data['value'])]; + }) + ->all(); + } +} diff --git a/src/models/Variant.php b/src/models/Variant.php index 991ca967..f07470f4 100644 --- a/src/models/Variant.php +++ b/src/models/Variant.php @@ -9,6 +9,7 @@ use craft\base\Model; use craft\helpers\Json; +use craft\shopify\helpers\Metafield as MetafieldHelper; use craft\shopify\Plugin; use DateTime; use yii\base\InvalidConfigException; @@ -148,21 +149,21 @@ public function getData(): array } /** - * @param string|array $value + * @param string|array $value A list-shaped array of `{key, value}` objects, or a JSON-encoded string of the same. * @return void + * @throws \InvalidArgumentException if the value is not a list-shaped array or JSON string of one. */ public function setMetafields(string|array $value): void { if (is_string($value)) { $value = Json::decodeIfJson($value); - $value = collect($value)->mapWithKeys(function($d) { - return [ - $d['key'] => Json::decodeIfJson($d['value']), - ]; - }); } - $this->_metaFields = $value; + if (!is_array($value) || !array_is_list($value)) { + throw new \InvalidArgumentException('setMetafields() expects a list-shaped array of {key, value} objects or a JSON-encoded string of the same.'); + } + + $this->_metaFields = MetafieldHelper::normalizeToMap($value); } /** @@ -181,14 +182,7 @@ public function getMetafields(): array $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid); - $metafields = $data - ->mapWithKeys(function($d) { - return [ - $d['key'] => Json::decodeIfJson($d['value']), - ]; - }); - - $this->setMetafields($metafields->all()); + $this->setMetafields($data->all()); return $this->_metaFields ?? []; } diff --git a/src/services/Products.php b/src/services/Products.php index d103c875..65a9bea3 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -8,7 +8,6 @@ use craft\events\ConfigEvent; use craft\helpers\ArrayHelper; use craft\helpers\Db; -use craft\helpers\Json; use craft\helpers\ProjectConfig; use craft\helpers\StringHelper; use craft\models\FieldLayout; @@ -281,23 +280,7 @@ public function deleteShopifyDataByShopifyId(string $shopifyId): void public function eagerLoadMetafieldsForProducts(array $products): array { return $this->_eagerLoadTypeOnProducts($products, 'Metafield', function($product, $rows) { - $metafields = collect($rows) - ->mapWithKeys(function($d, $key) { - /** @var ShopifyData $d */ - $data = Json::decodeIfJson($d->data); - - // Map if the data has `key` and `value` properties - if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) { - return []; - } - - return [ - $data['key'] => Json::decodeIfJson($data['value']), - ]; - }) - ->all(); - - $product->setMetafields($metafields); + $product->setMetafields($rows); }); } @@ -343,18 +326,10 @@ public function eagerLoadVariantsForProducts(array $products): array $variants = VariantCollection::make($variantsByProductId[$product->shopifyGid]); if ($metafieldsData->isNotEmpty()) { - $variants->map(function(Variant$variant) use ($metafieldsData) { + $variants->map(function(Variant $variant) use ($metafieldsData) { $metafields = $metafieldsData->get($variant->shopifyGid); if (!empty($metafields)) { - $variant->setMetafields(collect($metafields)->mapWithKeys(function($d) { - $data = Json::decodeIfJson($d->data); - if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) { - return []; - } - return [ - $data['key'] => Json::decodeIfJson($data['value']), - ]; - })->all()); + $variant->setMetafields($metafields->all()); } }); } diff --git a/tests/unit/elements/ProductTest.php b/tests/unit/elements/ProductTest.php new file mode 100644 index 00000000..3d477b81 --- /dev/null +++ b/tests/unit/elements/ProductTest.php @@ -0,0 +1,398 @@ +shopifyGid = self::PRODUCT_GID; + $product->shopifyId = self::PRODUCT_ID; + $product->shopifyStatus = $shopifyStatus; + return $product; + } + + // ------------------------------------------------------------------------- + // shopifyId + // ------------------------------------------------------------------------- + + public function testShopifyIdIsNullByDefault(): void + { + $product = new Product(); + self::assertNull($product->shopifyId); + } + + public function testShopifyIdIsInt(): void + { + $product = $this->_makeProduct(); + self::assertIsInt($product->shopifyId); + } + + public function testShopifyIdCanBeSetOnNewElement(): void + { + $product = new Product(); + $product->shopifyId = self::PRODUCT_ID; + self::assertEquals(self::PRODUCT_ID, $product->shopifyId); + } + + // ------------------------------------------------------------------------- + // shopifyGid + // ------------------------------------------------------------------------- + + public function testShopifyGidIsNullByDefault(): void + { + $product = new Product(); + self::assertNull($product->shopifyGid); + } + + public function testShopifyGidIsString(): void + { + $product = $this->_makeProduct(); + self::assertIsString($product->shopifyGid); + } + + public function testShopifyGidHasCorrectFormat(): void + { + $product = $this->_makeProduct(); + self::assertStringStartsWith('gid://shopify/Product/', $product->shopifyGid); + } + + public function testShopifyGidCanBeSetOnNewElement(): void + { + $product = new Product(); + $product->shopifyGid = self::PRODUCT_GID; + self::assertEquals(self::PRODUCT_GID, $product->shopifyGid); + } + + public function testShopifyGidNumericSegmentIsNumeric(): void + { + $product = $this->_makeProduct(); + $lastSegment = substr($product->shopifyGid, strrpos($product->shopifyGid, '/') + 1); + self::assertMatchesRegularExpression('/^\d+$/', $lastSegment); + } + + // ------------------------------------------------------------------------- + // shopifyId and shopifyGid relationship + // ------------------------------------------------------------------------- + + public function testShopifyIdMatchesNumericSegmentOfGid(): void + { + $product = $this->_makeProduct(); + $lastSegment = (int) substr($product->shopifyGid, strrpos($product->shopifyGid, '/') + 1); + self::assertEquals($lastSegment, $product->shopifyId); + } + + public function testShopifyGidContainsShopifyId(): void + { + $product = $this->_makeProduct(); + self::assertStringContainsString((string) $product->shopifyId, $product->shopifyGid); + } + + // ------------------------------------------------------------------------- + // shopifyStatus + // ------------------------------------------------------------------------- + + public function testShopifyStatusDefaultsToActive(): void + { + $product = new Product(); + self::assertEquals(Product::SHOPIFY_STATUS_ACTIVE, $product->shopifyStatus); + } + + public function testShopifyStatusActiveConstantValue(): void + { + self::assertEquals('active', Product::SHOPIFY_STATUS_ACTIVE); + } + + public function testShopifyStatusDraftConstantValue(): void + { + self::assertEquals('draft', Product::SHOPIFY_STATUS_DRAFT); + } + + public function testShopifyStatusArchivedConstantValue(): void + { + self::assertEquals('archived', Product::SHOPIFY_STATUS_ARCHIVED); + } + + public function testShopifyStatusCanBeSetToDraft(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_DRAFT); + self::assertEquals(Product::SHOPIFY_STATUS_DRAFT, $product->shopifyStatus); + } + + public function testShopifyStatusCanBeSetToArchived(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_ARCHIVED); + self::assertEquals(Product::SHOPIFY_STATUS_ARCHIVED, $product->shopifyStatus); + } + + // ------------------------------------------------------------------------- + // getStatus() + // ------------------------------------------------------------------------- + + public function testGetStatusReturnsLiveWhenEnabledAndActive(): void + { + $product = $this->_makeProduct(); + $product->enabled = true; + self::assertEquals(Product::STATUS_LIVE, $product->getStatus()); + } + + public function testGetStatusReturnsShopifyDraftWhenEnabledAndDraft(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_DRAFT); + $product->enabled = true; + self::assertEquals(Product::STATUS_SHOPIFY_DRAFT, $product->getStatus()); + } + + public function testGetStatusReturnsShopifyArchivedWhenEnabledAndArchived(): void + { + $product = $this->_makeProduct(Product::SHOPIFY_STATUS_ARCHIVED); + $product->enabled = true; + self::assertEquals(Product::STATUS_SHOPIFY_ARCHIVED, $product->getStatus()); + } + + public function testGetStatusReturnsDisabledWhenNotEnabled(): void + { + $product = $this->_makeProduct(); + $product->enabled = false; + self::assertEquals(Product::STATUS_DISABLED, $product->getStatus()); + } + + public function testGetStatusReturnsDisabledRegardlessOfShopifyStatus(): void + { + foreach ([Product::SHOPIFY_STATUS_ACTIVE, Product::SHOPIFY_STATUS_DRAFT, Product::SHOPIFY_STATUS_ARCHIVED] as $shopifyStatus) { + $product = $this->_makeProduct($shopifyStatus); + $product->enabled = false; + self::assertEquals(Product::STATUS_DISABLED, $product->getStatus(), "Expected disabled status for shopifyStatus=$shopifyStatus"); + } + } + + // ------------------------------------------------------------------------- + // tags + // ------------------------------------------------------------------------- + + public function testGetTagsReturnsEmptyArrayByDefault(): void + { + $product = new Product(); + self::assertSame([], $product->getTags()); + } + + public function testSetTagsAcceptsArray(): void + { + $product = new Product(); + $product->setTags(['sale', 'new']); + self::assertSame(['sale', 'new'], $product->getTags()); + } + + public function testSetTagsDecodesJsonString(): void + { + $product = new Product(); + $product->setTags('["sale","new"]'); + self::assertSame(['sale', 'new'], $product->getTags()); + } + + // ------------------------------------------------------------------------- + // options + // ------------------------------------------------------------------------- + + public function testGetOptionsReturnsEmptyArrayByDefault(): void + { + $product = new Product(); + self::assertSame([], $product->getOptions()); + } + + public function testSetOptionsAcceptsArray(): void + { + $product = new Product(); + $options = [['name' => 'Size', 'values' => ['S', 'M', 'L']]]; + $product->setOptions($options); + self::assertSame($options, $product->getOptions()); + } + + public function testSetOptionsDecodesJsonString(): void + { + $product = new Product(); + $product->setOptions('[{"name":"Size","values":["S","M"]}]'); + self::assertEquals('Size', $product->getOptions()[0]['name']); + self::assertSame(['S', 'M'], $product->getOptions()[0]['values']); + } + + // ------------------------------------------------------------------------- + // data + // ------------------------------------------------------------------------- + + public function testGetDataReturnsEmptyArrayByDefault(): void + { + $product = new Product(); + self::assertSame([], $product->getData()); + } + + public function testSetDataAcceptsArray(): void + { + $product = new Product(); + $product->setData(['title' => 'Test Product']); + self::assertSame(['title' => 'Test Product'], $product->getData()); + } + + public function testSetDataDecodesJsonString(): void + { + $product = new Product(); + $product->setData('{"title":"Test Product"}'); + self::assertEquals('Test Product', $product->getData()['title']); + } + + public function testSetDataNullResultsInEmptyArray(): void + { + $product = new Product(); + $product->setData(['title' => 'Test Product']); + $product->setData(null); + self::assertSame([], $product->getData()); + } + + // ------------------------------------------------------------------------- + // descriptionHtml + // ------------------------------------------------------------------------- + + public function testGetDescriptionHtmlReturnsNullWhenNoData(): void + { + $product = new Product(); + self::assertNull($product->getDescriptionHtml()); + } + + public function testGetDescriptionHtmlReturnsValueFromData(): void + { + $product = new Product(); + $product->setData(['descriptionHtml' => '

Hello

']); + self::assertEquals('

Hello

', $product->getDescriptionHtml()); + } + + // ------------------------------------------------------------------------- + // variants + // ------------------------------------------------------------------------- + + public function testGetVariantsReturnsEmptyCollectionWithNoShopifyGid(): void + { + $product = new Product(); + self::assertCount(0, $product->getVariants()); + } + + public function testSetVariantsWrapsPlainArrayInVariantCollection(): void + { + $product = $this->_makeProduct(); + $product->setVariants([]); + self::assertInstanceOf(VariantCollection::class, $product->getVariants()); + } + + public function testSetVariantsKeepsExistingVariantCollection(): void + { + $product = $this->_makeProduct(); + $collection = VariantCollection::make(); + $product->setVariants($collection); + self::assertSame($collection, $product->getVariants()); + } + + // ------------------------------------------------------------------------- + // images + // ------------------------------------------------------------------------- + + public function testGetImagesReturnsEmptyArrayWhenNoShopifyGid(): void + { + $product = new Product(); + self::assertSame([], $product->getImages()); + } + + public function testSetImagesAcceptsArray(): void + { + $product = $this->_makeProduct(); + $images = [['url' => 'https://example.com/img.jpg']]; + $product->setImages($images); + self::assertSame($images, $product->getImages()); + } + + public function testSetImagesDecodesJsonString(): void + { + $product = $this->_makeProduct(); + $product->setImages('[{"url":"https://example.com/img.jpg"}]'); + self::assertIsArray($product->getImages()); + self::assertCount(1, $product->getImages()); + } + + // ------------------------------------------------------------------------- + // metafields + // ------------------------------------------------------------------------- + + public function testGetMetafieldsReturnsEmptyArrayWhenNoShopifyGid(): void + { + $product = new Product(); + self::assertSame([], $product->getMetafields()); + } + + public function testSetMetafieldsAcceptsListArray(): void + { + $product = $this->_makeProduct(); + $product->setMetafields([['key' => 'colour', 'value' => 'red']]); + self::assertSame(['colour' => 'red'], $product->getMetafields()); + } + + public function testSetMetafieldsDecodesJsonListString(): void + { + $product = $this->_makeProduct(); + $product->setMetafields('[{"key":"colour","value":"red"}]'); + self::assertSame(['colour' => 'red'], $product->getMetafields()); + } + + public function testSetMetafieldsNormalizesRawListFormat(): void + { + $product = $this->_makeProduct(); + $product->setMetafields([['key' => 'colour', 'value' => 'red']]); + self::assertSame(['colour' => 'red'], $product->getMetafields()); + } + + public function testSetMetafieldsThrowsForAssociativeArray(): void + { + $product = new Product(); + self::expectException(\InvalidArgumentException::class); + $product->setMetafields(['colour' => 'red']); + } + + public function testSetMetafieldsThrowsForJsonEncodedAssociativeArray(): void + { + $product = new Product(); + self::expectException(\InvalidArgumentException::class); + $product->setMetafields('{"colour":"red"}'); + } + + // ------------------------------------------------------------------------- + // getCheapestVariant / getDefaultVariant + // ------------------------------------------------------------------------- + + public function testGetDefaultVariantReturnsNullWhenNoVariants(): void + { + $product = new Product(); + self::assertNull($product->getDefaultVariant()); + } + + public function testGetCheapestVariantReturnsNullWhenNoVariants(): void + { + $product = new Product(); + self::assertNull($product->getCheapestVariant()); + } +} diff --git a/tests/unit/services/ProductsTest.php b/tests/unit/services/ProductsTest.php index a9e36040..60e898b6 100644 --- a/tests/unit/services/ProductsTest.php +++ b/tests/unit/services/ProductsTest.php @@ -10,6 +10,7 @@ use Codeception\Test\Unit; use craft\shopify\collections\VariantCollection; use craft\shopify\db\Table; +use craft\shopify\helpers\Metafield as MetafieldHelper; use craft\shopify\Plugin; use craft\shopify\records\ShopifyData; use craft\shopify\tests\fixtures\ShopifyDataFixture; @@ -242,7 +243,7 @@ public function setImages(array $images): void public function setMetafields(array $metafields): void { - $this->metafields = $metafields; + $this->metafields = MetafieldHelper::normalizeToMap($metafields); } }; }, $shopifyGids); From 07f4688c4f53750e1006b4d589c2912edb790db7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 11:42:35 +0100 Subject: [PATCH 18/65] Tweak delete product --- src/services/Products.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/services/Products.php b/src/services/Products.php index 65a9bea3..d7283b22 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -192,21 +192,27 @@ public function normalizeShopifyGid(string $shopifyId, string $type = 'Product') * Deletes a product element by the Shopify GID. * * @param string $gid - * @return void + * @return bool Whether the deletion was performed. Returns false if `$gid` is empty. * @throws \Throwable * @throws StaleObjectException * @since 8.0.0 */ - public function deleteProductByShopifyGid(string $gid): void + public function deleteProductByShopifyGid(string $gid): bool { - if ($gid) { - if ($product = Product::find()->shopifyId($gid)->one()) { - // We hard delete because it will have been hard deleted in Shopify - Craft::$app->getElements()->deleteElement($product, true); - } + if (!$gid) { + return false; + } - $this->deleteShopifyDataByShopifyGid($this->normalizeShopifyGid($gid)); + $gid = $this->normalizeShopifyGid($gid); + + if ($product = Product::find()->shopifyGid($gid)->one()) { + // We hard delete because it will have been hard deleted in Shopify + Craft::$app->getElements()->deleteElement($product, true); } + + $this->deleteShopifyDataByShopifyGid($gid); + + return true; } /** From 5e25d3ce80207c335fcd37e7bb8448820cb40453 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 11:45:45 +0100 Subject: [PATCH 19/65] bump schema version --- src/Plugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Plugin.php b/src/Plugin.php index 744c00cd..9a6eee2b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -79,7 +79,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '7.1.0.0'; + public string $schemaVersion = '8.0.0'; /** * @inheritdoc From bee928a84b9b73b7296e7b33e273eaba7a675f71 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 14:01:24 +0100 Subject: [PATCH 20/65] Add default for little extra security --- src/jobs/ProcessBulkOperationData.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jobs/ProcessBulkOperationData.php b/src/jobs/ProcessBulkOperationData.php index 23655b9a..159d2fe9 100644 --- a/src/jobs/ProcessBulkOperationData.php +++ b/src/jobs/ProcessBulkOperationData.php @@ -23,7 +23,7 @@ class ProcessBulkOperationData extends BaseBatchedJob /** * @var string */ - public string $bulkOperationShopifyGid; + public string $bulkOperationShopifyGid = ''; /** * @var string From 7b583af0dfded9406e3f1146277bf591a4465fb9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 14:05:02 +0100 Subject: [PATCH 21/65] Tweak changeling Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG-WIP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index f6f2d415..888af6ee 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -11,8 +11,8 @@ - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. - `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. -- Deprecated `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId`. Use `$bulkOperationShopifyGid` instead. -- Deprecated `craft\shopify\models\BulkOperation::$shopifyId`. Use `$shopifyGid` instead. +- Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`. +- Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`. - Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::deleteProductByShopifyId()`. Use `deleteProductByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::deleteShopifyDataByShopifyId()`. Use `deleteShopifyDataByShopifyGid()` instead. From c6b73c2538d2390a075523ca8183d1e0a13cfec1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 14:05:33 +0100 Subject: [PATCH 22/65] Tweak deprecated method --- src/services/Products.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/Products.php b/src/services/Products.php index d7283b22..f89e2a53 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -224,6 +224,10 @@ public function deleteProductByShopifyGid(string $gid): bool */ public function deleteProductByShopifyId($id): void { + if (!$id) { + return; + } + $this->deleteProductByShopifyGid($id); } From 2966ee698b75c089a2149c392050f2209b289520 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 15:46:33 +0100 Subject: [PATCH 23/65] Tweak context config event naming --- README.md | 8 ++++---- ...tEvent.php => DefineContextConfigEvent.php} | 2 +- src/services/Api.php | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) rename src/events/{DefineInitializeApiContextEvent.php => DefineContextConfigEvent.php} (90%) diff --git a/README.md b/README.md index 9471888c..871949d1 100644 --- a/README.md +++ b/README.md @@ -1434,13 +1434,13 @@ The event object has one property: ```php use craft\base\Event; -use craft\shopify\events\DefineInitializeApiContextEvent; +use craft\shopify\events\DefineContextConfigEvent; use craft\shopify\services\Api; Event::on( Api::class, - Api::EVENT_DEFINE_INITIALIZE_API_CONTEXT, - function(DefineInitializeApiContextEvent $event) { + Api::EVENT_DEFINE_CONTEXT_CONFIG, + function(DefineContextConfigEvent $event) { // Disable the Shopify API logger: $event->config['logger'] = null; } @@ -1458,7 +1458,7 @@ use Shopify\Context; Event::on( Api::class, - Api::EVENT_AFTER_INITIALIZE_API_CONTEXT, + Api::EVENT_CONTEXT_INITIALIZED, function(Event $event) { // Replace the HTTP client factory with a custom implementation: Context::$HTTP_CLIENT_FACTORY = new MyHttpClientFactory(); diff --git a/src/events/DefineInitializeApiContextEvent.php b/src/events/DefineContextConfigEvent.php similarity index 90% rename from src/events/DefineInitializeApiContextEvent.php rename to src/events/DefineContextConfigEvent.php index ab9fedd7..b7bea498 100644 --- a/src/events/DefineInitializeApiContextEvent.php +++ b/src/events/DefineContextConfigEvent.php @@ -15,7 +15,7 @@ * @author Pixel & Tonic, Inc. * @since 7.2.0 */ -class DefineInitializeApiContextEvent extends Event +class DefineContextConfigEvent extends Event { /** * @var array Array of the arguments used to initialize the API context (`Context::initialize()`). diff --git a/src/services/Api.php b/src/services/Api.php index 60accd0d..4ae6117e 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -15,7 +15,7 @@ use craft\log\MonologTarget; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; -use craft\shopify\events\DefineInitializeApiContextEvent; +use craft\shopify\events\DefineContextConfigEvent; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; @@ -83,16 +83,16 @@ class Api extends Component public const EVENT_DEFINE_GQL_QUERY_ARGUMENTS = 'defineGqlQueryArguments'; /** - * @event DefineInitializeApiContextEvent Trigged before initializing the Shopify API context, which is required for authentication and making API calls. + * @event DefineContextConfigEvent Trigged before initializing the Shopify API context, which is required for authentication and making API calls. * @since 7.2.0 */ - public const EVENT_DEFINE_INITIALIZE_API_CONTEXT = 'defineInitializeApiContext'; + public const EVENT_DEFINE_CONTEXT_CONFIG = 'defineContextConfig'; /** * @event Event Triggered after the Shopify API context has been initialized, which is required for authentication and making API calls. * @since 7.2.0 */ - public const EVENT_AFTER_INITIALIZE_API_CONTEXT = 'afterInitializeApiContext'; + public const EVENT_CONTEXT_INITIALIZED = 'contextInitialized'; /** * @var Session|null @@ -666,10 +666,10 @@ public function initializeContext(): void 'logger' => $webLogTarget->getLogger(), ]; - if ($this->hasEventHandlers(self::EVENT_DEFINE_INITIALIZE_API_CONTEXT)) { - $event = new DefineInitializeApiContextEvent(['config' => $contextConfig]); + if ($this->hasEventHandlers(self::EVENT_DEFINE_CONTEXT_CONFIG)) { + $event = new DefineContextConfigEvent(['config' => $contextConfig]); - $this->trigger(self::EVENT_DEFINE_INITIALIZE_API_CONTEXT, $event); + $this->trigger(self::EVENT_DEFINE_CONTEXT_CONFIG, $event); $contextConfig = $event->config; } @@ -683,8 +683,8 @@ public function client(): ClientInterface } }; - if ($this->hasEventHandlers(self::EVENT_AFTER_INITIALIZE_API_CONTEXT)) { - $this->trigger(self::EVENT_AFTER_INITIALIZE_API_CONTEXT); + if ($this->hasEventHandlers(self::EVENT_CONTEXT_INITIALIZED)) { + $this->trigger(self::EVENT_CONTEXT_INITIALIZED); } } From e5abd77763305c725f9550dd063f072a1350c4ba Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 30 Jun 2026 15:49:33 +0100 Subject: [PATCH 24/65] fix cs --- src/services/Api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Api.php b/src/services/Api.php index 4ae6117e..fc02f41f 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -13,9 +13,9 @@ use craft\helpers\Json; use craft\helpers\StringHelper; use craft\log\MonologTarget; +use craft\shopify\events\DefineContextConfigEvent; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; -use craft\shopify\events\DefineContextConfigEvent; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; From ce5cb027905980103ba169a1fd560f59ce94d050 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 1 Jul 2026 14:19:28 +0100 Subject: [PATCH 25/65] Fix event docs --- CHANGELOG-WIP.md | 6 +++--- README.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 6163c731..80419948 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -13,7 +13,7 @@ ### Extensibility - Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. -- Added `craft\shopify\events\DefineInitializeApiContextEvent`. +- Added `craft\shopify\events\DefineContextConfigEvent`. - Added `craft\shopify\models\BulkOperation::$shopifyGid`. - Added `craft\shopify\models\Settings::REQUIRED_SCOPES`. - Added `craft\shopify\models\Settings::getAdditionalFeatures()`. @@ -28,8 +28,8 @@ - Added `craft\shopify\services\Products::deleteProductByShopifyGid()`. - Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. -- Added `craft\shopify\services\Api::EVENT_AFTER_INITIALIZE_API_CONTEXT`. -- Added `craft\shopify\services\Api::EVENT_DEFINE_INITIALIZE_API_CONTEXT`. +- Added `craft\shopify\services\Api::EVENT_CONTEXT_INITIALIZED`. +- Added `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG`. - Added `craft\shopify\services\Api::getShopLocalesGql()`. - `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. diff --git a/README.md b/README.md index 871949d1..7efbfea3 100644 --- a/README.md +++ b/README.md @@ -1424,9 +1424,9 @@ Event::on( Using this event, after the queries have been built, you have the opportunity to add custom arguments to the main query. For example, you can tailor a query for products using the [ProductConnection arguments](https://shopify.dev/docs/api/admin-graphql/2026-01/queries/products#arguments) (like `query`, `reverse`, or `savedSearchId`). -#### `craft\shopify\services\Api::EVENT_DEFINE_INITIALIZE_API_CONTEXT` +#### `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG` -Emitted before the Shopify API context is initialized. The `craft\shopify\events\DefineInitializeApiContextEvent` object exposes a `$config` array containing the arguments that will be passed to [`Context::initialize()`](https://github.com/Shopify/shopify-api-php/blob/main/docs/getting_started.md), allowing you to customize the context before it is applied. +Emitted before the Shopify API context is initialized. The `craft\shopify\events\DefineContextConfigEvent` object exposes a `$config` array containing the arguments that will be passed to [`Context::initialize()`](https://github.com/Shopify/shopify-api-php/blob/main/docs/getting_started.md), allowing you to customize the context before it is applied. The event object has one property: @@ -1447,7 +1447,7 @@ Event::on( ); ``` -#### `craft\shopify\services\Api::EVENT_AFTER_INITIALIZE_API_CONTEXT` +#### `craft\shopify\services\Api::EVENT_CONTEXT_INITIALIZED` Emitted after the Shopify API context has been fully initialized. Use this event to perform setup that depends on a ready context, such as overriding the HTTP client factory. From 0847d1e660fdf095f9922ee8cc0283662023f127 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 2 Jul 2026 08:12:03 +0100 Subject: [PATCH 26/65] Added upgrading section Two section needs merging or altering as required for those upgrading from older versions --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 7efbfea3..f52cf249 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,20 @@ Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://sho ## Upgrading +### From 7.x + +> [!WARNING] +> Ensure the Craft queue is fully drained before upgrading. Any pending sync jobs will be unable to update their status after the migration runs. + +Shopify for Craft 8.0 requires **Craft CMS 5.10.7 or later** and drops support for Craft 4. + +`craft\shopify\models\Variant::$shopifyId` now holds only the **numeric** Shopify ID (e.g. `”123456789”`). The full GID (e.g. `”gid://shopify/ProductVariant/123456789”`) is available via the new `$shopifyGid` property. Update any templates or custom code that compared or used `$variant->shopifyId` as a GID string. + +> [!TIP] +> The [changelog](https://github.com/craftcms/shopify/blob/8.x/CHANGELOG.md) contains a full list of added, changed, and deprecated classes and methods. + +### From 6.x + This version (7.x) is primarily concerned with Shopify API compatibility, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable. From c21de855672ae68b03b5d215d1856f2b5e03e152 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 2 Jul 2026 08:44:39 +0100 Subject: [PATCH 27/65] Tidy settings additional features for better typing --- src/controllers/SettingsController.php | 5 +++++ src/models/Settings.php | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index e1e07a39..b17d679c 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -322,6 +322,11 @@ public function actionSaveSettings(): ?Response $pluginSettings = $plugin->getSettings(); $originalUriFormat = $pluginSettings->uriFormat; + // Expand the checkboxSelect "All" wildcard to the full list of feature handles + if (($settings['additionalFeatures'] ?? null) === '*') { + $settings['additionalFeatures'] = array_keys($pluginSettings->getAdditionalFeaturesOptions()); + } + // Remove from editable table namespace $settings['uriFormat'] = $settings['routing']['uriFormat']; // Could be blank if in headless mode diff --git a/src/models/Settings.php b/src/models/Settings.php index 827d3afc..7f8e4ff9 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -253,12 +253,8 @@ public function getHostName(bool $parse = true): string * @return void * @since 7.2.0 */ - public function setAdditionalFeatures(array|string $additionalFeatures): void + public function setAdditionalFeatures(array $additionalFeatures): void { - if ($additionalFeatures === '*') { - $additionalFeatures = array_keys($this->getAdditionalFeaturesOptions()); - } - $this->_additionalFeatures = $additionalFeatures; } From 82030a1888c671561ddaa22c029e7d7ff36e99da Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 2 Jul 2026 08:46:41 +0100 Subject: [PATCH 28/65] tidy --- src/events/DefineContextConfigEvent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/DefineContextConfigEvent.php b/src/events/DefineContextConfigEvent.php index b7bea498..b3e6d311 100644 --- a/src/events/DefineContextConfigEvent.php +++ b/src/events/DefineContextConfigEvent.php @@ -20,5 +20,5 @@ class DefineContextConfigEvent extends Event /** * @var array Array of the arguments used to initialize the API context (`Context::initialize()`). */ - public array $config; + public array $config = []; } From 2b4169859f1f66a2c98128e3fef451b0b36bbd74 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 2 Jul 2026 08:53:12 +0100 Subject: [PATCH 29/65] DRY normalising the API scopes --- src/models/Settings.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/models/Settings.php b/src/models/Settings.php index 7f8e4ff9..73544c75 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -305,7 +305,7 @@ public function setCustomScopes(string $additionalScopes): void // Preserve env var references as-is; normalize plain-text values if (!str_starts_with($additionalScopes, '$')) { $additionalScopes = implode(',', array_filter(array_map( - fn($s) => preg_match('/^[a-z0-9_]+$/', $normalized = strtolower(trim($s))) ? $normalized : '', + fn($s) => self::_normalizeScope($s), explode(',', $additionalScopes) ))); } @@ -335,7 +335,7 @@ public function getScopes(bool $asArray = false): array|string $customScopes = $this->getCustomScopes(); if ($customScopes) { $scopes = array_merge($scopes, array_filter(array_map( - fn($s) => preg_match('/^[a-z0-9_]+$/', $normalized = strtolower(trim($s))) ? $normalized : '', + fn($s) => self::_normalizeScope($s), explode(',', $customScopes) ))); } @@ -350,6 +350,16 @@ public function getScopes(bool $asArray = false): array|string return implode(',', $scopes); } + /** + * Normalizes a single scope string: lowercases, trims whitespace, and returns an empty string if the result + * contains characters outside `[a-z0-9_]`. + */ + private static function _normalizeScope(string $scope): string + { + $normalized = strtolower(trim($scope)); + return preg_match('/^[a-z0-9_]+$/', $normalized) ? $normalized : ''; + } + /** * @param string $accessToken * @return void From d2b1bb4dbd1a97352a9a19fb9e9659476143ebb3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 2 Jul 2026 11:30:26 +0100 Subject: [PATCH 30/65] Tidy settings JS --- src/controllers/SettingsController.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index b17d679c..0a343e39 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -245,7 +245,7 @@ public function actionIndex(?Settings $settings = null): Response }).then(response => { scopesInput.value = response.data.scopes; }).catch(() => { - Craft.cp.displayError(Craft.t('shopify', 'Couldn't update scopes.')); + Craft.cp.displayError(Craft.t('shopify', 'Couldn’t update scopes.')); }); }; @@ -253,9 +253,6 @@ public function actionIndex(?Settings $settings = null): Response if (e.target.name === 'settings[additionalFeatures][]' || e.target.name === 'settings[additionalFeatures]') { // Defer so Craft's checkbox-select JS can toggle related checkboxes first setTimeout(updateScopes, 0); - } else if (e.target.id === 'customScopes') { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(updateScopes, 300); } }); @@ -269,6 +266,10 @@ public function actionIndex(?Settings $settings = null): Response JS; $this->getView()->registerJs($js); + $this->getView()->registerTranslations('shopify', [ + 'Couldn’t update scopes.' + ]); + $screen = $this->asCpScreen() ->title(Craft::t('shopify', 'Settings')) ->tabs([ @@ -322,8 +323,10 @@ public function actionSaveSettings(): ?Response $pluginSettings = $plugin->getSettings(); $originalUriFormat = $pluginSettings->uriFormat; + $settings['additionalFeatures'] = $settings['additionalFeatures'] ?: []; + // Expand the checkboxSelect "All" wildcard to the full list of feature handles - if (($settings['additionalFeatures'] ?? null) === '*') { + if ($settings['additionalFeatures'] === '*') { $settings['additionalFeatures'] = array_keys($pluginSettings->getAdditionalFeaturesOptions()); } From 3463eebcbf46f15f6a13f81322e0241d7f822609 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 2 Jul 2026 12:58:46 +0100 Subject: [PATCH 31/65] Use helper method --- src/services/Api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/Api.php b/src/services/Api.php index fc02f41f..f525b02c 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -415,8 +415,8 @@ public function getProductGql(?string $id = null): Query return $this->createQuery('products', $fields, function(QueryBuilder $builder) use ($id) { if ($id) { - // Strip Shopify prefix if it exists - $id = str_replace('gid://shopify/Product/', '', $id); + // Extract the numeric ID from a full GID or pass through a bare numeric ID + $id = StringHelper::afterLast($id, '/') ?: $id; $builder->setArgument('query', sprintf('id:%s', $id)); } From efbd25e4d858cee9190d2d3319361c53e52dee3a Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 2 Jul 2026 13:00:08 +0100 Subject: [PATCH 32/65] fix cs --- src/controllers/SettingsController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 0a343e39..969b214c 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -267,7 +267,7 @@ public function actionIndex(?Settings $settings = null): Response $this->getView()->registerJs($js); $this->getView()->registerTranslations('shopify', [ - 'Couldn’t update scopes.' + 'Couldn’t update scopes.', ]); $screen = $this->asCpScreen() From 85441f17f093cd4bcf11d134f6a1921b1bbb4b17 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 13 Jul 2026 16:32:32 +0100 Subject: [PATCH 33/65] WIP drop SDK usage --- CHANGELOG-WIP.md | 23 +- README.md | 45 +- composer.json | 3 +- composer.lock | 1455 +++++++++------------ src/Plugin.php | 11 +- src/auth/OAuthFlow.php | 60 + src/clients/GraphqlClient.php | 64 + src/console/controllers/ApiController.php | 4 +- src/controllers/AuthController.php | 59 +- src/controllers/SettingsController.php | 2 +- src/controllers/WebhookController.php | 14 +- src/controllers/WebhooksController.php | 21 +- src/enums/ApiVersion.php | 21 + src/events/DefineContextConfigEvent.php | 24 - src/exceptions/InvalidOAuthException.php | 18 + src/exceptions/ShopifyApiException.php | 18 + src/handlers/Webhook.php | 35 +- src/helpers/ShopifyHelper.php | 72 + src/models/Settings.php | 8 +- src/services/Api.php | 219 +--- src/services/BulkOperations.php | 6 +- src/webhooks/WebhookRegistry.php | 68 + src/webhooks/WebhookTopics.php | 38 + 23 files changed, 1142 insertions(+), 1146 deletions(-) create mode 100644 src/auth/OAuthFlow.php create mode 100644 src/clients/GraphqlClient.php create mode 100644 src/enums/ApiVersion.php delete mode 100644 src/events/DefineContextConfigEvent.php create mode 100644 src/exceptions/InvalidOAuthException.php create mode 100644 src/exceptions/ShopifyApiException.php create mode 100644 src/helpers/ShopifyHelper.php create mode 100644 src/webhooks/WebhookRegistry.php create mode 100644 src/webhooks/WebhookTopics.php diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 80419948..7f2d46eb 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -8,12 +8,12 @@ - Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215)) - It’s now possible to view the required API scopes in the plugin settings. - It’s now possible to extend the API scopes with opt-in additional features and custom scopes. -- It’s now possible to customize the Shopify API context before and after initialization via new events. +- Added support for the 2026-04 and 2026-07 Shopify API versions. +- Product inventory now also syncs when Shopify sends an `inventory_items/update` webhook. ### Extensibility - Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. -- Added `craft\shopify\events\DefineContextConfigEvent`. - Added `craft\shopify\models\BulkOperation::$shopifyGid`. - Added `craft\shopify\models\Settings::REQUIRED_SCOPES`. - Added `craft\shopify\models\Settings::getAdditionalFeatures()`. @@ -28,21 +28,36 @@ - Added `craft\shopify\services\Products::deleteProductByShopifyGid()`. - Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. -- Added `craft\shopify\services\Api::EVENT_CONTEXT_INITIALIZED`. -- Added `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG`. - Added `craft\shopify\services\Api::getShopLocalesGql()`. +- Added `craft\shopify\services\Api::connect()`. +- Added `craft\shopify\auth\OAuthFlow`. +- Added `craft\shopify\clients\GraphqlClient`. +- Added `craft\shopify\enums\ApiVersion`. +- Added `craft\shopify\exceptions\InvalidOAuthException`. +- Added `craft\shopify\exceptions\ShopifyApiException`. +- Added `craft\shopify\helpers\ShopifyHelper`. +- Added `craft\shopify\webhooks\WebhookRegistry`. +- Added `craft\shopify\webhooks\WebhookTopics`. - `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. +- `craft\shopify\services\Api::WEBHOOK_TOPICS` now contains `craft\shopify\webhooks\WebhookTopics` enum cases instead of plain strings. +- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\webhooks\WebhookTopics` enum instead of a string. +- API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. - Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`. - Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`. - Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::deleteProductByShopifyId()`. Use `deleteProductByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::deleteShopifyDataByShopifyId()`. Use `deleteShopifyDataByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::syncProductByShopifyId()`. Use `syncProductByShopifyGid()` instead. +- Removed `craft\shopify\services\Api::getSession()`. Use `connect()` instead. +- Removed `craft\shopify\services\Api::initializeContext()`. ### System - The `shopify_data` table's `shopifyId` column has been renamed to `shopifyGid`. A new generated `shopifyId` column (the numeric ID at the end of the GID) has been added. - The `shopify_bulkoperations` table's `shopifyId` column has been renamed to `shopifyGid`. - Fixed a bug where validation errors for the "Context Pricing Countries" setting weren't displaying correctly. +- Fixed a bug where `inventory_levels/update` webhooks weren't triggering a product sync. +- Removed the `shopify/shopify-api` Composer dependency. - Shopify for Craft now requires Craft CMS 5.10.7 or later. diff --git a/README.md b/README.md index f52cf249..bd57cffa 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,9 @@ Shopify for Craft 8.0 requires **Craft CMS 5.10.7 or later** and drops support f `craft\shopify\models\Variant::$shopifyId` now holds only the **numeric** Shopify ID (e.g. `”123456789”`). The full GID (e.g. `”gid://shopify/ProductVariant/123456789”`) is available via the new `$shopifyGid` property. Update any templates or custom code that compared or used `$variant->shopifyId` as a GID string. +> [!WARNING] +> The `shopify/shopify-api` package is no longer a dependency of this plugin. If any custom code references its classes directly—like `Shopify\Clients\Graphql`, `Shopify\Exception\ShopifyException`, `Shopify\Webhooks\Registry`, `Shopify\Auth\OAuth`, or `Shopify\Context`—update it to use the plugin’s own equivalents (`craft\shopify\clients\GraphqlClient`, `craft\shopify\exceptions\ShopifyApiException`, `craft\shopify\webhooks\WebhookRegistry`, `craft\shopify\auth\OAuthFlow`) instead. + > [!TIP] > The [changelog](https://github.com/craftcms/shopify/blob/8.x/CHANGELOG.md) contains a full list of added, changed, and deprecated classes and methods. @@ -1438,48 +1441,6 @@ Event::on( Using this event, after the queries have been built, you have the opportunity to add custom arguments to the main query. For example, you can tailor a query for products using the [ProductConnection arguments](https://shopify.dev/docs/api/admin-graphql/2026-01/queries/products#arguments) (like `query`, `reverse`, or `savedSearchId`). -#### `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG` - -Emitted before the Shopify API context is initialized. The `craft\shopify\events\DefineContextConfigEvent` object exposes a `$config` array containing the arguments that will be passed to [`Context::initialize()`](https://github.com/Shopify/shopify-api-php/blob/main/docs/getting_started.md), allowing you to customize the context before it is applied. - -The event object has one property: - -- `config`: Array of arguments passed to `Context::initialize()`, including `apiKey`, `apiSecretKey`, `scopes`, `hostName`, `sessionStorage`, `apiVersion`, `isEmbeddedApp`, and `logger`. - -```php -use craft\base\Event; -use craft\shopify\events\DefineContextConfigEvent; -use craft\shopify\services\Api; - -Event::on( - Api::class, - Api::EVENT_DEFINE_CONTEXT_CONFIG, - function(DefineContextConfigEvent $event) { - // Disable the Shopify API logger: - $event->config['logger'] = null; - } -); -``` - -#### `craft\shopify\services\Api::EVENT_CONTEXT_INITIALIZED` - -Emitted after the Shopify API context has been fully initialized. Use this event to perform setup that depends on a ready context, such as overriding the HTTP client factory. - -```php -use craft\base\Event; -use craft\shopify\services\Api; -use Shopify\Context; - -Event::on( - Api::class, - Api::EVENT_CONTEXT_INITIALIZED, - function(Event $event) { - // Replace the HTTP client factory with a custom implementation: - Context::$HTTP_CLIENT_FACTORY = new MyHttpClientFactory(); - } -); -``` - ### GraphQL Playground In addition to the [template helper](#api-service), you can execute queries against the Admin GraphQL API via Craft’s CLI: diff --git a/composer.json b/composer.json index 4e6cd3f2..374099b4 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,7 @@ "require": { "php": "^8.2", "carnage/php-graphql-client": "^1.14", - "craftcms/cms": "^5.10.7", - "shopify/shopify-api": "^6.0.0" + "craftcms/cms": "^5.10.7" }, "require-dev": { "codeception/codeception": "^5.0.11", diff --git a/composer.lock b/composer.lock index b7b8331d..5475b716 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1e11372d30c0d8dfb70aa25e285717a4", + "content-hash": "d3aea8b0ac73aaddae88a754879fc7b5", "packages": [ { "name": "bacon/bacon-qr-code", @@ -538,16 +538,16 @@ }, { "name": "craftcms/cms", - "version": "5.10.8", + "version": "5.10.8.1", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556" + "reference": "dc39613556a054f7674a3bdccdb813b8b7e28b34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/ec387a529a7f9a0c07f2a086c59dadc02fc79556", - "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556", + "url": "https://api.github.com/repos/craftcms/cms/zipball/dc39613556a054f7674a3bdccdb813b8b7e28b34", + "reference": "dc39613556a054f7674a3bdccdb813b8b7e28b34", "shasum": "" }, "require": { @@ -664,7 +664,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-06-23T11:02:47+00:00" + "time": "2026-06-23T15:45:02+00:00" }, { "name": "craftcms/plugin-installer", @@ -1428,94 +1428,28 @@ }, "time": "2025-10-17T16:34:55+00:00" }, - { - "name": "firebase/php-jwt", - "version": "v7.1.0", - "source": { - "type": "git", - "url": "https://github.com/googleapis/php-jwt.git", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.4", - "phpfastcache/phpfastcache": "^9.2", - "phpseclib/phpseclib": "~3.0", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^2.0||^3.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" - }, - "suggest": { - "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", - "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" - }, - "type": "library", - "autoload": { - "psr-4": { - "Firebase\\JWT\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" - }, - { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" - } - ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/googleapis/php-jwt", - "keywords": [ - "jwt", - "php" - ], - "support": { - "issues": "https://github.com/googleapis/php-jwt/issues", - "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" - }, - "time": "2026-06-11T17:54:14+00:00" - }, { "name": "guzzlehttp/guzzle", - "version": "7.12.1", + "version": "7.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.1", + "guzzlehttp/psr7": "^2.12.3", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1524,7 +1458,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5.1", + "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1604,7 +1538,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.1" + "source": "https://github.com/guzzle/guzzle/tree/7.13.1" }, "funding": [ { @@ -1620,7 +1554,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T14:12:49+00:00" + "time": "2026-06-29T20:14:18+00:00" }, { "name": "guzzlehttp/promises", @@ -1708,16 +1642,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.12.1", + "version": "2.12.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "shasum": "" }, "require": { @@ -1726,7 +1660,7 @@ "psr/http-message": "^1.1 || ^2.0", "ralouphie/getallheaders": "^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1807,7 +1741,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.1" + "source": "https://github.com/guzzle/psr7/tree/2.12.3" }, "funding": [ { @@ -1823,7 +1757,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T09:49:37+00:00" + "time": "2026-06-23T15:21:08+00:00" }, { "name": "illuminate/collections", @@ -2275,31 +2209,31 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.1.2", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-zlib": "*", - "php-64bit": "^8.2" + "php-64bit": "^8.3" }, "require-dev": { "brianium/paratest": "^7.7", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.16", + "friendsofphp/php-cs-fixer": "^3.86", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^11.0", + "phpunit/phpunit": "^12.0", "vimeo/psalm": "^6.0" }, "suggest": { @@ -2341,7 +2275,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" }, "funding": [ { @@ -2349,7 +2283,7 @@ "type": "github" } ], - "time": "2025-01-27T12:07:53+00:00" + "time": "2026-04-11T18:38:28+00:00" }, { "name": "markbaker/complex", @@ -2460,16 +2394,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -2477,7 +2411,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -2521,9 +2455,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" }, { "name": "mikehaertl/php-shellcommand", @@ -4039,160 +3973,6 @@ }, "time": "2019-03-08T08:55:37+00:00" }, - { - "name": "ramsey/collection", - "version": "2.1.1", - "source": { - "type": "git", - "url": "https://github.com/ramsey/collection.git", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.45", - "fakerphp/faker": "^1.24", - "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^2.1", - "mockery/mockery": "^1.6", - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpspec/prophecy-phpunit": "^2.3", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5", - "ramsey/coding-standard": "^2.3", - "ramsey/conventional-commits": "^1.6", - "roave/security-advisories": "dev-latest" - }, - "type": "library", - "extra": { - "captainhook": { - "force-install": true - }, - "ramsey/conventional-commits": { - "configFile": "conventional-commits.json" - } - }, - "autoload": { - "psr-4": { - "Ramsey\\Collection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ben Ramsey", - "email": "ben@benramsey.com", - "homepage": "https://benramsey.com" - } - ], - "description": "A PHP library for representing and manipulating collections.", - "keywords": [ - "array", - "collection", - "hash", - "map", - "queue", - "set" - ], - "support": { - "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.1.1" - }, - "time": "2025-03-22T05:38:12+00:00" - }, - { - "name": "ramsey/uuid", - "version": "4.9.3", - "source": { - "type": "git", - "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", - "shasum": "" - }, - "require": { - "brick/math": ">=0.8.16 <=0.18", - "php": "^8.0", - "ramsey/collection": "^1.2 || ^2.0" - }, - "replace": { - "rhumsaa/uuid": "self.version" - }, - "require-dev": { - "captainhook/captainhook": "^5.25", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "ergebnis/composer-normalize": "^2.47", - "mockery/mockery": "^1.6", - "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.6", - "php-mock/php-mock-mockery": "^1.5", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpbench/phpbench": "^1.2.14", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^8.18", - "squizlabs/php_codesniffer": "^3.13" - }, - "suggest": { - "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", - "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", - "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", - "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." - }, - "type": "library", - "extra": { - "captainhook": { - "force-install": true - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Ramsey\\Uuid\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", - "keywords": [ - "guid", - "identifier", - "uuid" - ], - "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" - }, - "time": "2026-06-18T03:57:49+00:00" - }, { "name": "samdark/yii2-psr-log-target", "version": "1.1.4", @@ -4310,80 +4090,6 @@ }, "time": "2020-12-15T21:32:01+00:00" }, - { - "name": "shopify/shopify-api", - "version": "v6.1.1", - "source": { - "type": "git", - "url": "https://github.com/Shopify/shopify-api-php.git", - "reference": "80e89587db4c6f111b52029a29a0f26277300b76" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Shopify/shopify-api-php/zipball/80e89587db4c6f111b52029a29a0f26277300b76", - "reference": "80e89587db4c6f111b52029a29a0f26277300b76", - "shasum": "" - }, - "require": { - "doctrine/inflector": "^2.0", - "ext-ctype": "*", - "ext-hash": "*", - "ext-json": "*", - "ext-mbstring": "*", - "firebase/php-jwt": "^7.0", - "guzzlehttp/guzzle": "^7.0", - "guzzlehttp/psr7": "^2.0", - "php": "^8.1", - "psr/http-client": "^1.0", - "psr/http-message": "^1.0 || ^2.0", - "psr/log": "^1.1 || ^2.0 || ^3.0", - "ramsey/uuid": "^4.1" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.30", - "ext-apcu": "*", - "maglnet/composer-require-checker": "^3.0 || ^4.0", - "mikey179/vfsstream": "^1.6", - "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.6" - }, - "suggest": { - "ext-apcu": "Log fewer API deprecation warnings" - }, - "type": "library", - "autoload": { - "psr-4": { - "Shopify\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Shopify Inc.", - "email": "dev-tools-education@shopify.com" - } - ], - "description": "Shopify API Library for PHP", - "keywords": [ - "Storefront API", - "admin api", - "app", - "graphql", - "jwt", - "node", - "rest", - "shopify", - "webhook" - ], - "support": { - "issues": "https://github.com/Shopify/shopify-api-php/issues", - "source": "https://github.com/Shopify/shopify-api-php/tree/v6.1.1" - }, - "time": "2026-03-02T21:44:12+00:00" - }, { "name": "spomky-labs/cbor-php", "version": "3.2.3", @@ -4714,16 +4420,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -4761,7 +4467,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4781,7 +4487,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/dom-crawler", @@ -4857,24 +4563,25 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.4.9", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -4883,14 +4590,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4918,7 +4625,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" }, "funding": [ { @@ -4938,20 +4645,20 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-06-09T12:28:30+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -4998,7 +4705,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -5018,7 +4725,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", @@ -5092,16 +4799,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", "shasum": "" }, "require": { @@ -5169,7 +4876,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.13" + "source": "https://github.com/symfony/http-client/tree/v7.4.14" }, "funding": [ { @@ -5189,20 +4896,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T09:57:54+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -5251,7 +4958,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -5271,20 +4978,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", "shasum": "" }, "require": { @@ -5335,7 +5042,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.12" + "source": "https://github.com/symfony/mailer/tree/v7.4.14" }, "funding": [ { @@ -5355,7 +5062,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-13T08:51:35+00:00" }, { "name": "symfony/mime", @@ -6433,16 +6140,16 @@ }, { "name": "symfony/serializer", - "version": "v7.4.10", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd" + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/268c5aa6c4bd675eddd89348e7ecac292a843ddd", - "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd", + "url": "https://api.github.com/repos/symfony/serializer/zipball/55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", "shasum": "" }, "require": { @@ -6513,7 +6220,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.10" + "source": "https://github.com/symfony/serializer/tree/v7.4.14" }, "funding": [ { @@ -6533,20 +6240,20 @@ "type": "tidelift" } ], - "time": "2026-05-03T13:03:28+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -6600,7 +6307,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -6620,39 +6327,38 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v7.4.13", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6691,7 +6397,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.13" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -6711,20 +6417,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T15:23:29+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation", - "version": "v6.4.38", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "afaa31b0c12d9a659eed1ea97f268a614cc1299c" + "reference": "fef99cef37890b350976f5f492854faefadd4e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/afaa31b0c12d9a659eed1ea97f268a614cc1299c", - "reference": "afaa31b0c12d9a659eed1ea97f268a614cc1299c", + "url": "https://api.github.com/repos/symfony/translation/zipball/fef99cef37890b350976f5f492854faefadd4e15", + "reference": "fef99cef37890b350976f5f492854faefadd4e15", "shasum": "" }, "require": { @@ -6790,7 +6496,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.38" + "source": "https://github.com/symfony/translation/tree/v6.4.42" }, "funding": [ { @@ -6810,20 +6516,20 @@ "type": "tidelift" } ], - "time": "2026-05-06T08:55:54+00:00" + "time": "2026-06-05T16:46:18+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -6872,7 +6578,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6892,26 +6598,25 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/type-info", - "version": "v7.4.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", - "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" }, "conflict": { "phpstan/phpdoc-parser": "<1.30" @@ -6955,7 +6660,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.9" + "source": "https://github.com/symfony/type-info/tree/v8.1.0" }, "funding": [ { @@ -6975,7 +6680,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/uid", @@ -7057,16 +6762,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -7120,7 +6825,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -7140,20 +6845,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", "shasum": "" }, "require": { @@ -7196,7 +6901,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.13" + "source": "https://github.com/symfony/yaml/tree/v7.4.14" }, "funding": [ { @@ -7216,7 +6921,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T06:06:12+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "theiconic/name-parser", @@ -8532,16 +8237,16 @@ }, { "name": "codeception/lib-innerbrowser", - "version": "4.1.0", + "version": "4.1.1", "source": { "type": "git", "url": "https://github.com/Codeception/lib-innerbrowser.git", - "reference": "8af7f8402f976b32f67a83dfd4e31f8f5b1f7db3" + "reference": "0fa80deaed7da6a92a0cd4117338394c69196ec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/8af7f8402f976b32f67a83dfd4e31f8f5b1f7db3", - "reference": "8af7f8402f976b32f67a83dfd4e31f8f5b1f7db3", + "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/0fa80deaed7da6a92a0cd4117338394c69196ec6", + "reference": "0fa80deaed7da6a92a0cd4117338394c69196ec6", "shasum": "" }, "require": { @@ -8585,9 +8290,9 @@ ], "support": { "issues": "https://github.com/Codeception/lib-innerbrowser/issues", - "source": "https://github.com/Codeception/lib-innerbrowser/tree/4.1.0" + "source": "https://github.com/Codeception/lib-innerbrowser/tree/4.1.1" }, - "time": "2026-02-07T10:09:13+00:00" + "time": "2026-06-26T22:06:27+00:00" }, { "name": "codeception/lib-web", @@ -9814,16 +9519,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "11.0.12", + "version": "13.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + "reference": "2ea1bcdad040326c02edd6519cc9d1c5a9f6c87e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2ea1bcdad040326c02edd6519cc9d1c5a9f6c87e", + "reference": "2ea1bcdad040326c02edd6519cc9d1c5a9f6c87e", "shasum": "" }, "require": { @@ -9831,18 +9536,16 @@ "ext-libxml": "*", "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-text-template": "^4.0.1", - "sebastian/code-unit-reverse-lookup": "^4.0.1", - "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.1", - "sebastian/lines-of-code": "^3.0.1", - "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.3.1" + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.46" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -9851,7 +9554,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0.x-dev" + "dev-main": "13.0.x-dev" } }, "autoload": { @@ -9880,7 +9583,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/13.0.2" }, "funding": [ { @@ -9900,32 +9603,32 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:01:01+00:00" + "time": "2026-04-01T14:12:38+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9953,7 +9656,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, "funding": [ { @@ -9973,28 +9676,28 @@ "type": "tidelift" } ], - "time": "2026-02-02T13:52:54+00:00" + "time": "2026-02-06T04:33:26+00:00" }, { "name": "phpunit/php-invoker", - "version": "5.0.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-pcntl": "*" @@ -10002,7 +9705,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10029,40 +9732,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" } ], - "time": "2024-07-03T05:07:44+00:00" + "time": "2026-02-06T04:34:47+00:00" }, { "name": "phpunit/php-text-template", - "version": "4.0.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10089,40 +9804,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" } ], - "time": "2024-07-03T05:08:43+00:00" + "time": "2026-02-06T04:36:37+00:00" }, { "name": "phpunit/php-timer", - "version": "7.0.1", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -10149,28 +9876,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" } ], - "time": "2024-07-03T05:09:35+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { "name": "phpunit/phpunit", - "version": "11.5.55", + "version": "13.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "reference": "9e426f7282c313c9138eeb9f25461e1a6be1e647" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9e426f7282c313c9138eeb9f25461e1a6be1e647", + "reference": "9e426f7282c313c9138eeb9f25461e1a6be1e647", "shasum": "" }, "require": { @@ -10183,35 +9922,31 @@ "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.1", - "phpunit/php-invoker": "^5.0.1", - "phpunit/php-text-template": "^4.0.1", - "phpunit/php-timer": "^7.0.1", - "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.3", - "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.2", - "sebastian/global-state": "^7.0.2", - "sebastian/object-enumerator": "^6.0.1", - "sebastian/recursion-context": "^6.0.3", - "sebastian/type": "^5.1.3", - "sebastian/version": "^5.0.2", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^13.0.1", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.0.0", + "sebastian/diff": "^8.0.0", + "sebastian/environment": "^9.1.0", + "sebastian/exporter": "^8.0.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.0", + "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" - }, "bin": [ "phpunit" ], "type": "library", "extra": { "branch-alias": { - "dev-main": "11.5-dev" + "dev-main": "13.0-dev" } }, "autoload": { @@ -10243,44 +9978,28 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.6" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2026-03-31T06:44:39+00:00" }, { "name": "psy/psysh", - "version": "v0.12.23", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -10344,9 +10063,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2026-05-23T13:41:31+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "rector/rector", @@ -10409,28 +10128,28 @@ }, { "name": "sebastian/cli-parser", - "version": "3.0.2", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10454,152 +10173,51 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2024-07-03T04:41:36+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" - }, - "funding": [ + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2025-03-19T07:56:08+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" - }, - "funding": [ + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2024-07-03T04:45:54+00:00" + "time": "2026-02-06T04:39:44+00:00" }, { "name": "sebastian/comparator", - "version": "6.3.3", + "version": "8.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" + "php": ">=8.4", + "sebastian/diff": "^8.3", + "sebastian/exporter": "^8.0.3" }, "require-dev": { - "phpunit/phpunit": "^11.4" + "phpunit/phpunit": "^13.1.10" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -10607,7 +10225,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "8.2-dev" } }, "autoload": { @@ -10647,7 +10265,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.2.1" }, "funding": [ { @@ -10667,33 +10285,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:26:40+00:00" + "time": "2026-05-21T04:46:40+00:00" }, { "name": "sebastian/complexity", - "version": "4.0.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10717,41 +10335,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" } ], - "time": "2024-07-03T04:49:50+00:00" + "time": "2026-02-06T04:41:32+00:00" }, { "name": "sebastian/diff", - "version": "6.0.2", + "version": "8.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "8.3-dev" } }, "autoload": { @@ -10784,35 +10414,47 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2024-07-03T04:53:05+00:00" + "time": "2026-05-15T04:58:09+00:00" }, { "name": "sebastian/environment", - "version": "7.2.1", + "version": "9.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^13.1.11" }, "suggest": { "ext-posix": "*" @@ -10820,7 +10462,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.2-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -10848,7 +10490,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" }, "funding": [ { @@ -10868,34 +10510,34 @@ "type": "tidelift" } ], - "time": "2025-05-21T11:55:47+00:00" + "time": "2026-05-25T13:41:38+00:00" }, { "name": "sebastian/exporter", - "version": "6.3.2", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -10938,7 +10580,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" }, "funding": [ { @@ -10958,35 +10600,35 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:12:51+00:00" + "time": "2026-05-21T11:50:56+00:00" }, { "name": "sebastian/global-state", - "version": "7.0.2", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.1.13" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -11012,41 +10654,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-07-03T04:57:36+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "3.0.1", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" + "nikic/php-parser": "^5.7.0", + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -11070,42 +10724,54 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2024-07-03T04:58:38+00:00" + "time": "2026-05-19T16:23:37+00:00" }, { "name": "sebastian/object-enumerator", - "version": "6.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -11128,40 +10794,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2024-07-03T05:00:13+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { "name": "sebastian/object-reflector", - "version": "4.0.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11184,40 +10862,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2024-07-03T05:01:32+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { "name": "sebastian/recursion-context", - "version": "6.0.3", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -11248,7 +10938,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" }, "funding": [ { @@ -11268,32 +10958,32 @@ "type": "tidelift" } ], - "time": "2025-08-13T04:42:22+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { "name": "sebastian/type", - "version": "5.1.3", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + "reference": "fee0309275847fefd7636167085e379c1dbf6990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11317,7 +11007,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" }, "funding": [ { @@ -11337,29 +11027,29 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:55:48+00:00" + "time": "2026-05-20T06:49:11+00:00" }, { "name": "sebastian/version", - "version": "5.0.2", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11383,15 +11073,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" } ], - "time": "2024-10-09T05:16:32+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "seld/jsonlint", @@ -11511,28 +11213,27 @@ }, { "name": "symfony/browser-kit", - "version": "v7.4.8", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521" + "reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/41850d8f8ddef9a9cd7314fa9f4902cf48885521", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5", + "reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/dom-crawler": "^6.4|^7.0|^8.0" + "php": ">=8.4.1", + "symfony/dom-crawler": "^7.4|^8.0" }, "require-dev": { - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -11560,7 +11261,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.4.8" + "source": "https://github.com/symfony/browser-kit/tree/v8.1.1" }, "funding": [ { @@ -11580,51 +11281,53 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-09T10:54:51+00:00" }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" + "symfony/string": "^7.4.6|^8.0.6" }, "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -11658,7 +11361,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v8.1.1" }, "funding": [ { @@ -11678,27 +11381,27 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-06-16T12:55:20+00:00" }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -11726,7 +11429,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v8.1.1" }, "funding": [ { @@ -11746,7 +11449,87 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T09:05:56+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symplify/easy-coding-standard", @@ -11806,23 +11589,23 @@ }, { "name": "theseer/tokenizer", - "version": "1.3.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -11844,7 +11627,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -11852,7 +11635,7 @@ "type": "github" } ], - "time": "2025-11-17T20:03:58+00:00" + "time": "2025-12-08T11:19:18+00:00" }, { "name": "vlucas/phpdotenv", @@ -11952,5 +11735,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Plugin.php b/src/Plugin.php index 9a6eee2b..57e3e5eb 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -57,7 +57,7 @@ use craft\web\twig\variables\CraftVariable; use craft\web\UrlManager; use GraphQL\Query as GqlQuery; -use Shopify\Webhooks\Registry; +use craft\shopify\webhooks\WebhookRegistry; use yii\base\Event; use yii\base\InvalidConfigException; @@ -164,7 +164,7 @@ public function init() // Globally register shopify webhooks registry event handlers foreach ($this->getApi()::WEBHOOK_TOPICS as $topic) { - Registry::addHandler($topic, new Webhook()); + WebhookRegistry::addHandler($topic, new Webhook()); } } @@ -388,8 +388,7 @@ public function _registerResaveCommands(): void private function _registerCpRoutes(): void { Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_CP_URL_RULES, function(RegisterUrlRulesEvent $event) { - $session = Plugin::getInstance()->getApi()->getSession(); - $event->rules['shopify'] = ['template' => 'shopify/_index', 'variables' => ['hasSession' => (bool)$session]]; + $event->rules['shopify'] = ['template' => 'shopify/_index', 'variables' => ['hasSession' => Plugin::getInstance()->getApi()->connect()]]; $event->rules['shopify/products'] = 'shopify/products/product-index'; $event->rules['shopify/sync-products'] = 'shopify/products/sync'; @@ -525,7 +524,7 @@ public function getCpNavItem(): ?array $ret = parent::getCpNavItem(); $ret['label'] = Craft::t('shopify', 'Shopify'); - $session = Plugin::getInstance()->getApi()->getSession(); + $connected = Plugin::getInstance()->getApi()->connect(); $ret['subnav']['products'] = [ 'label' => Craft::t('shopify', 'Products'), @@ -537,7 +536,7 @@ public function getCpNavItem(): ?array 'url' => 'shopify/settings', ]; - if ($session) { + if ($connected) { if (Craft::$app->getUser()->getIsAdmin()) { $ret['subnav']['webhooks'] = [ 'label' => Craft::t('shopify', 'Webhooks'), diff --git a/src/auth/OAuthFlow.php b/src/auth/OAuthFlow.php new file mode 100644 index 00000000..5b0f7e7b --- /dev/null +++ b/src/auth/OAuthFlow.php @@ -0,0 +1,60 @@ + + * @since 8.0.0 + */ +class OAuthFlow +{ + public const STATE_COOKIE_NAME = 'shopify_app_state'; + public const STATE_SIG_COOKIE_NAME = 'shopify_app_state_sig'; + public const ACCESS_TOKEN_POST_PATH = '/admin/oauth/access_token'; + + /** + * Begins the OAuth authorization flow. + * + * Sets two signed state cookies (via $setCookieFunction) and returns the Shopify authorization URL. + * + * @param callable $setCookieFunction fn(string $name, string $value, int $expire): bool + */ + public static function begin( + string $hostName, + bool $isOnline, + string $clientId, + string $scopes, + string $clientSecret, + callable $setCookieFunction, + ): string { + $state = StringHelper::UUID(); + $sig = hash_hmac('sha256', $state, $clientSecret); + $expire = time() + 60; + + $setCookieFunction(self::STATE_COOKIE_NAME, $state, $expire); + $setCookieFunction(self::STATE_SIG_COOKIE_NAME, $sig, $expire); + + $redirectUri = Plugin::getInstance()->getSettings()->getAuthUrl(); + + $query = http_build_query([ + 'client_id' => $clientId, + 'scope' => $scopes, + 'redirect_uri' => $redirectUri, + 'state' => $state, + 'grant_options' => $isOnline ? ['per-user'] : [], + ]); + + return "https://{$hostName}/admin/oauth/authorize?{$query}"; + } +} diff --git a/src/clients/GraphqlClient.php b/src/clients/GraphqlClient.php new file mode 100644 index 00000000..5e8fc0cb --- /dev/null +++ b/src/clients/GraphqlClient.php @@ -0,0 +1,64 @@ + + * @since 8.0.0 + */ +class GraphqlClient +{ + private Client $_client; + + public function __construct( + string $shop, + string $accessToken, + private string $apiVersion, + ) { + $this->_client = new Client([ + 'base_uri' => "https://{$shop}", + 'headers' => [ + 'X-Shopify-Access-Token' => $accessToken, + 'Content-Type' => 'application/json', + 'X-Shopify-Api-Features' => 'include-presentment-prices', + ], + ]); + } + + /** + * Executes a GraphQL query against the Shopify Admin API. + * + * @param array $data An array with at minimum a `query` key, optionally `variables`. + * @return array The decoded response body. + * @throws ShopifyApiException on HTTP or communication failure. + */ + public function query(array $data, array $extraHeaders = []): array + { + try { + $options = ['json' => $data]; + if ($extraHeaders) { + $options['headers'] = $extraHeaders; + } + + $response = $this->_client->post( + "admin/api/{$this->apiVersion}/graphql.json", + $options, + ); + + return json_decode((string)$response->getBody(), true) ?? []; + } catch (GuzzleException $e) { + throw new ShopifyApiException($e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/src/console/controllers/ApiController.php b/src/console/controllers/ApiController.php index e42adb8e..9998a692 100644 --- a/src/console/controllers/ApiController.php +++ b/src/console/controllers/ApiController.php @@ -11,7 +11,7 @@ use craft\console\Controller; use craft\helpers\Console; use craft\shopify\Plugin; -use Shopify\Exception\ShopifyException; +use craft\shopify\exceptions\ShopifyApiException; use yii\console\ExitCode; /** @@ -43,7 +43,7 @@ public function actionQuery(string $gql): int $this->stdout("Running query... "); $data = Plugin::getInstance()->getApi()->query($gql); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { $err = $e->getMessage(); } diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php index 1c1833af..2fc46c27 100644 --- a/src/controllers/AuthController.php +++ b/src/controllers/AuthController.php @@ -9,14 +9,12 @@ use Craft; use craft\helpers\Html; +use craft\shopify\auth\OAuthFlow; +use craft\shopify\exceptions\InvalidOAuthException; +use craft\shopify\helpers\ShopifyHelper; use craft\shopify\Plugin; use craft\web\Controller; use craft\web\Response; -use Shopify\Auth\OAuth; -use Shopify\Auth\OAuthCookie; -use Shopify\Context; -use Shopify\Exception\InvalidOAuthException; -use Shopify\Utils; use yii\web\Cookie; use yii\web\Response as YiiResponse; @@ -49,13 +47,12 @@ public function beforeAction($action): bool */ public function actionIndex(): YiiResponse { - Plugin::getInstance()->getApi()->initializeContext(); $settings = Plugin::getInstance()->getSettings(); $screen = $this->asCpScreen() ->title(Craft::t('shopify', 'Authorization')); - $validHmac = Utils::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret()); + $validHmac = ShopifyHelper::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret()); if (!$validHmac) { $html = $this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), Craft::t('shopify', 'Invalid or missing HMAC. Please try re-installing the app.')); return $screen->contentHtml($html); @@ -66,7 +63,7 @@ public function actionIndex(): YiiResponse if ($code) { $cookies = Craft::$app->getRequest()->getCookies()->toArray(); foreach ($cookies as $name => $cookie) { - if (!in_array($name, [OAuth::STATE_COOKIE_NAME, OAuth::STATE_SIG_COOKIE_NAME]) || !$cookie instanceof Cookie) { + if (!in_array($name, [OAuthFlow::STATE_COOKIE_NAME, OAuthFlow::STATE_SIG_COOKIE_NAME]) || !$cookie instanceof Cookie) { continue; } @@ -74,7 +71,7 @@ public function actionIndex(): YiiResponse } try { - $accessToken = $this->_fetchAccessToken($cookies, Craft::$app->getRequest()->getQueryParams(), fn(OAuthCookie $oauthCookie) => $this->_setCookies($oauthCookie, $screen)); + $accessToken = $this->_fetchAccessToken($cookies, Craft::$app->getRequest()->getQueryParams(), fn(string $n, string $v, int $e) => $this->_setCookies($n, $v, $e, $screen)); if (!$accessToken) { throw new InvalidOAuthException('Failed to retrieve access token.'); @@ -104,9 +101,15 @@ public function actionIndex(): YiiResponse } // If no code is present, it means the user is initiating the authorization process. - $path = Plugin::getInstance()->getSettings()->getAuthPath(); $shop = Craft::$app->getRequest()->getQueryParam('shop'); - $authorizeUrl = OAuth::begin($settings->getHostName(), $path, false, fn(OAuthCookie $oauthCookie) => $this->_setCookies($oauthCookie, $screen)); + $authorizeUrl = OAuthFlow::begin( + $settings->getHostName(), + false, + $settings->getClientId(), + $settings->getScopes(), + $settings->getClientSecret(), + fn(string $n, string $v, int $e) => $this->_setCookies($n, $v, $e, $screen), + ); $html = Html::beginTag('div', ['class' => 'flex flex-justify-center']) . Html::beginTag('div', ['class' => 'pane centeralign', 'style' => 'max-width: 400px']) . @@ -128,22 +131,15 @@ public function actionIndex(): YiiResponse * @param callable|null $setCookieFunction * @return string|null * @throws InvalidOAuthException - * @throws \Shopify\Exception\PrivateAppException - * @throws \Shopify\Exception\UninitializedContextException - * @throws \yii\base\InvalidConfigException */ private function _fetchAccessToken(array $cookies, array $query, ?callable $setCookieFunction = null): ?string { - Context::throwIfUninitialized(); - Context::throwIfPrivateApp('OAuth is not allowed for private apps'); - - // `getCookie()` - $signature = $cookies[OAuth::STATE_SIG_COOKIE_NAME] ?? null; - $cookieId = $cookies[OAuth::STATE_COOKIE_NAME] ?? null; + $signature = $cookies[OAuthFlow::STATE_SIG_COOKIE_NAME] ?? null; + $cookieId = $cookies[OAuthFlow::STATE_COOKIE_NAME] ?? null; $cookieState = null; if ($signature && $cookieId) { - $expectedSignature = hash_hmac('sha256', (string) $cookieId, Context::$API_SECRET_KEY); + $expectedSignature = hash_hmac('sha256', (string)$cookieId, Plugin::getInstance()->getSettings()->getClientSecret()); if ($signature === $expectedSignature) { $cookieState = $cookieId; @@ -154,7 +150,7 @@ private function _fetchAccessToken(array $cookies, array $query, ?callable $setC throw new InvalidOAuthException('Invalid OAuth callback.'); } - $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); + $sanitizedShop = ShopifyHelper::sanitizeShopDomain($query['shop'] ?? ''); return Plugin::getInstance()->getApi()->getAccessToken($query['code'], $sanitizedShop, true); } @@ -163,32 +159,29 @@ private function _fetchAccessToken(array $cookies, array $query, ?callable $setC * @param string|null $stateCookie * @return bool */ - private static function _isCallbackQueryValid(array $query, string | null $stateCookie): bool + private static function _isCallbackQueryValid(array $query, string|null $stateCookie): bool { - $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); + $sanitizedShop = ShopifyHelper::sanitizeShopDomain($query['shop'] ?? ''); $state = $query['state'] ?? ''; $code = $query['code'] ?? ''; return ( ($code) && ($sanitizedShop) && - ($state && $stateCookie && strcmp($stateCookie, (string) $state) === 0) && - Utils::validateHmac($query, Context::$API_SECRET_KEY) + ($state && $stateCookie && strcmp($stateCookie, (string)$state) === 0) && + ShopifyHelper::validateHmac($query, Plugin::getInstance()->getSettings()->getClientSecret()) ); } /** - * @param OAuthCookie $oauthCookie - * @param Response $screen - * @return bool * @throws \yii\base\InvalidConfigException */ - private function _setCookies(OAuthCookie $oauthCookie, Response $screen): bool + private function _setCookies(string $name, string $value, int $expire, Response $screen): bool { $cookieConfig = Craft::cookieConfig([ - 'name' => $oauthCookie->getName(), - 'value' => $oauthCookie->getValue(), - 'expire' => $oauthCookie->getExpire(), + 'name' => $name, + 'value' => $value, + 'expire' => $expire, ]); $cookie = Craft::createObject(array_merge($cookieConfig, ['class' => Cookie::class])); diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 969b214c..f67665da 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -62,7 +62,7 @@ public function actionIndex(?Settings $settings = null): Response 'name' => 'settings[authUrl]', 'value' => $settings->getAuthUrl(), 'readonly' => true, - 'warning' => !Plugin::getInstance()->getApi()->getSession() ? Craft::t('shopify', 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.') : null, + 'warning' => !Plugin::getInstance()->getApi()->connect() ? Craft::t('shopify', 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.') : null, ]; $scopesFieldConfig = [ diff --git a/src/controllers/WebhookController.php b/src/controllers/WebhookController.php index eddf2260..fe13dfca 100644 --- a/src/controllers/WebhookController.php +++ b/src/controllers/WebhookController.php @@ -9,8 +9,8 @@ use Craft; use craft\shopify\Plugin; +use craft\shopify\webhooks\WebhookRegistry; use craft\web\Controller; -use Shopify\Webhooks\Registry; use yii\web\MethodNotAllowedHttpException; use yii\web\Response as YiiResponse; @@ -35,16 +35,16 @@ public function actionHandle(): YiiResponse { $request = Craft::$app->getRequest(); - if (!Plugin::getInstance()->getApi()->getSession()) { + if (!Plugin::getInstance()->getApi()->connect()) { throw new MethodNotAllowedHttpException('No Shopify API session found, check credentials in settings.'); } try { - $response = Registry::process($request->headers->toArray(), $request->getRawBody()); - - if (!$response->isSuccess()) { - Craft::error("Webhook handler failed with message:" . $response->getErrorMessage()); - } + WebhookRegistry::process( + $request->headers->toArray(), + $request->getRawBody(), + Plugin::getInstance()->getSettings()->getClientSecret(), + ); } catch (\Exception $error) { Craft::error($error->getMessage()); } diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index d29b1c0a..8fa36e19 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -10,10 +10,11 @@ use Craft; use craft\helpers\Html; use craft\shopify\Plugin; +use craft\shopify\webhooks\WebhookTopics; use craft\web\Controller; use GraphQL\Query; use GraphQL\Variable; -use Shopify\Exception\ShopifyException; +use craft\shopify\exceptions\ShopifyApiException; use yii\web\ConflictHttpException; use yii\web\Response as YiiResponse; @@ -47,16 +48,18 @@ public function beforeAction($action): bool */ public function actionEdit(): YiiResponse { - $view = $this->getView(); $api = Plugin::getInstance()->getApi(); try { $webhooks = $api->getWebhooks(); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { throw new ConflictHttpException('There was an issue connecting to the Shopify API. Please check your credentials.'); } - $requiredTopics = array_flip($api::WEBHOOK_TOPICS); + $requiredTopics = array_flip(array_map( + fn($t) => $t->toGraphQLEnum(), + $api::WEBHOOK_TOPICS, + )); foreach ($webhooks as $hook) { // When we discover a new topic, yank from the “required” array: @@ -178,7 +181,7 @@ public function actionCreate(): ?YiiResponse try { $webhooks = $api->getWebhooks(); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { throw new ConflictHttpException('There was an issue connecting to the Shopify API. Please check your credentials.'); } @@ -187,7 +190,7 @@ public function actionCreate(): ?YiiResponse // Check each required topic and create missing subscriptions: foreach ($api::WEBHOOK_TOPICS as $topic) { // Is there at least one webhook with this topic? - if ($webhooks->contains('topic', $topic)) { + if ($webhooks->contains('topic', $topic->toGraphQLEnum())) { continue; } @@ -218,7 +221,7 @@ public function actionCreate(): ?YiiResponse ]); $variables = [ - 'topic' => $topic, + 'topic' => $topic->toGraphQLEnum(), 'webhookSubscription' => [ 'format' => 'JSON', 'uri' => Plugin::getInstance()->getSettings()->getWebhookUrl(), @@ -228,7 +231,7 @@ public function actionCreate(): ?YiiResponse try { // Fire it off; if anything goes wrong, we’ll just catch + log it. $api->query($query, $variables); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { Craft::error('Could not register webhooks with Shopify API: ' . $e->getMessage(), __METHOD__); $errors[] = $e->getMessage(); } @@ -253,7 +256,7 @@ public function actionDelete(): YiiResponse try { Plugin::getInstance()->getApi()->deleteWebhookById($id); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { return $this->asFailure(Craft::t('shopify', 'Webhook could not be deleted')); } diff --git a/src/enums/ApiVersion.php b/src/enums/ApiVersion.php new file mode 100644 index 00000000..3c39d718 --- /dev/null +++ b/src/enums/ApiVersion.php @@ -0,0 +1,21 @@ + + * @since 8.0.0 + */ +enum ApiVersion: string +{ + case January2026 = '2026-01'; + case April2026 = '2026-04'; + case July2026 = '2026-07'; +} diff --git a/src/events/DefineContextConfigEvent.php b/src/events/DefineContextConfigEvent.php deleted file mode 100644 index b3e6d311..00000000 --- a/src/events/DefineContextConfigEvent.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @since 7.2.0 - */ -class DefineContextConfigEvent extends Event -{ - /** - * @var array Array of the arguments used to initialize the API context (`Context::initialize()`). - */ - public array $config = []; -} diff --git a/src/exceptions/InvalidOAuthException.php b/src/exceptions/InvalidOAuthException.php new file mode 100644 index 00000000..eada0177 --- /dev/null +++ b/src/exceptions/InvalidOAuthException.php @@ -0,0 +1,18 @@ + + * @since 8.0.0 + */ +class InvalidOAuthException extends \RuntimeException +{ +} diff --git a/src/exceptions/ShopifyApiException.php b/src/exceptions/ShopifyApiException.php new file mode 100644 index 00000000..4dd7acf0 --- /dev/null +++ b/src/exceptions/ShopifyApiException.php @@ -0,0 +1,18 @@ + + * @since 8.0.0 + */ +class ShopifyApiException extends \RuntimeException +{ +} diff --git a/src/handlers/Webhook.php b/src/handlers/Webhook.php index 700944d8..de5c2e11 100644 --- a/src/handlers/Webhook.php +++ b/src/handlers/Webhook.php @@ -8,8 +8,7 @@ namespace craft\shopify\handlers; use craft\shopify\Plugin; -use Shopify\Webhooks\Handler; -use Shopify\Webhooks\Topics; +use craft\shopify\webhooks\WebhookTopics; /** * Webhook handler. @@ -17,28 +16,18 @@ * @author Pixel & Tonic, Inc. * @since 6.0.0 */ -class Webhook implements Handler +class Webhook { - public function handle(string $topic, string $shop, array $body): void + public function handle(WebhookTopics $topic, string $shop, array $body): void { - switch ($topic) { - case Topics::PRODUCTS_UPDATE: - case Topics::PRODUCTS_CREATE: - Plugin::getInstance()->getProducts()->syncProductByShopifyGid($body['id']); - break; - case Topics::PRODUCTS_DELETE: - Plugin::getInstance()->getProducts()->deleteProductByShopifyGid($body['id']); - break; - case Topics::INVENTORY_ITEMS_UPDATE: - Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']); - break; - case Topics::BULK_OPERATIONS_FINISH: - Plugin::getInstance()->getBulkOperations()->handleBulkOperationFinished($body); - break; - case Topics::SHOP_UPDATE: - // Unfortunately, the shop data in the webhook differs to that returned by the GraphQl API. - Plugin::getInstance()->getApi()->getShop(true); - break; - } + match ($topic) { + WebhookTopics::ProductsCreate, + WebhookTopics::ProductsUpdate => Plugin::getInstance()->getProducts()->syncProductByShopifyGid($body['id']), + WebhookTopics::ProductsDelete => Plugin::getInstance()->getProducts()->deleteProductByShopifyGid($body['id']), + WebhookTopics::InventoryLevelsUpdate => Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['inventory_item_id']), + WebhookTopics::InventoryItemsUpdate => Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']), + WebhookTopics::BulkOperationsFinish => Plugin::getInstance()->getBulkOperations()->handleBulkOperationFinished($body), + WebhookTopics::ShopUpdate => Plugin::getInstance()->getApi()->getShop(true), + }; } } diff --git a/src/helpers/ShopifyHelper.php b/src/helpers/ShopifyHelper.php new file mode 100644 index 00000000..981fd587 --- /dev/null +++ b/src/helpers/ShopifyHelper.php @@ -0,0 +1,72 @@ + + * @since 8.0.0 + */ +class ShopifyHelper +{ + /** + * Validates and normalizes a Shopify shop domain. + * + * Returns the sanitized domain string, or null if the value is not a valid Shopify domain. + */ + public static function sanitizeShopDomain(string $shop): ?string + { + $shop = strtolower(trim($shop)); + + // Strip protocol prefix + $shop = preg_replace('#^https?://#', '', $shop); + + // If no dot, assume it's a myshopify subdomain + if (!str_contains($shop, '.')) { + $shop .= '.myshopify.com'; + } + + if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9\-]*\.(myshopify\.com|myshopify\.io)$/', $shop)) { + return null; + } + + return $shop; + } + + /** + * Validates the HMAC signature on a set of Shopify query parameters. + */ + public static function validateHmac(array $params, string $secret): bool + { + if (!isset($params['hmac'])) { + return false; + } + + $hmac = $params['hmac']; + $computed = hash_hmac('sha256', self::_buildQueryString($params), $secret); + + return hash_equals($computed, $hmac); + } + + private static function _buildQueryString(array $params): string + { + unset($params['hmac']); + ksort($params); + + $pairs = []; + foreach ($params as $key => $value) { + if (is_array($value)) { + $value = '["' . implode('","', $value) . '"]'; + } + $pairs[] = urlencode((string)$key) . '=' . urlencode((string)$value); + } + + return implode('&', $pairs); + } +} diff --git a/src/models/Settings.php b/src/models/Settings.php index 73544c75..064edcea 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -14,10 +14,10 @@ use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use craft\shopify\elements\Product; +use craft\shopify\enums\ApiVersion; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; -use Shopify\ApiVersion; -use Shopify\Utils; +use craft\shopify\helpers\ShopifyHelper; /** * Shopify Settings model. @@ -50,7 +50,7 @@ class Settings extends Model * @see setApiVersion() * @see getApiVersion() */ - private string $_apiVersion = ApiVersion::JANUARY_2026; + private string $_apiVersion = ApiVersion::January2026->value; public function rules(): array { @@ -62,7 +62,7 @@ public function rules(): array [['hostName'], function($attribute) { $hostName = $this->$attribute; - if (Utils::sanitizeShopDomain($hostName) === null) { + if (ShopifyHelper::sanitizeShopDomain($hostName) === null) { $this->addError($attribute, Craft::t('shopify', 'The host name must be a valid Shopify store domain.')); } }, 'skipOnEmpty' => true], diff --git a/src/services/Api.php b/src/services/Api.php index f525b02c..bf114f86 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -12,8 +12,6 @@ use craft\helpers\ArrayHelper; use craft\helpers\Json; use craft\helpers\StringHelper; -use craft\log\MonologTarget; -use craft\shopify\events\DefineContextConfigEvent; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; use craft\shopify\Plugin; @@ -23,23 +21,13 @@ use GraphQL\Query; use GraphQL\QueryBuilder\QueryBuilder; use GraphQL\Variable; +use craft\shopify\auth\OAuthFlow; +use craft\shopify\clients\GraphqlClient; +use craft\shopify\enums\ApiVersion; +use craft\shopify\exceptions\ShopifyApiException; +use craft\shopify\webhooks\WebhookTopics; use GuzzleHttp\Client; use Illuminate\Support\Collection; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; -use Shopify\ApiVersion; -use Shopify\Auth\FileSessionStorage; -use Shopify\Auth\OAuth; -use Shopify\Auth\Session; -use Shopify\Clients\Graphql; -use Shopify\Clients\Http; -use Shopify\Clients\HttpClientFactory; -use Shopify\Context; -use Shopify\Exception\MissingArgumentException; -use Shopify\Exception\SessionNotFoundException; -use Shopify\Exception\ShopifyException; -use Shopify\Exception\UninitializedContextException; -use Shopify\Webhooks\Topics; use yii\base\InvalidConfigException; /** @@ -57,12 +45,13 @@ class Api extends Component * @since 6.0.0 */ public const WEBHOOK_TOPICS = [ - Topics::PRODUCTS_CREATE, - Topics::PRODUCTS_UPDATE, - Topics::PRODUCTS_DELETE, - Topics::INVENTORY_LEVELS_UPDATE, - Topics::BULK_OPERATIONS_FINISH, - Topics::SHOP_UPDATE, + WebhookTopics::ProductsCreate, + WebhookTopics::ProductsUpdate, + WebhookTopics::ProductsDelete, + WebhookTopics::InventoryLevelsUpdate, + WebhookTopics::InventoryItemsUpdate, + WebhookTopics::BulkOperationsFinish, + WebhookTopics::ShopUpdate, ]; /** @@ -83,36 +72,22 @@ class Api extends Component public const EVENT_DEFINE_GQL_QUERY_ARGUMENTS = 'defineGqlQueryArguments'; /** - * @event DefineContextConfigEvent Trigged before initializing the Shopify API context, which is required for authentication and making API calls. - * @since 7.2.0 - */ - public const EVENT_DEFINE_CONTEXT_CONFIG = 'defineContextConfig'; - - /** - * @event Event Triggered after the Shopify API context has been initialized, which is required for authentication and making API calls. - * @since 7.2.0 - */ - public const EVENT_CONTEXT_INITIALIZED = 'contextInitialized'; - - /** - * @var Session|null + * @var string|null */ - private ?Session $_session = null; + private ?string $_accessToken = null; /** - * @var Graphql|null + * @var GraphqlClient|null */ - private ?Graphql $_gqlClient = null; + private ?GraphqlClient $_gqlClient = null; /** - * @return array + * @return string[] * @since 5.3.0 */ public function getSupportedApiVersions(): array { - return [ - ApiVersion::JANUARY_2026, - ]; + return array_column(ApiVersion::cases(), 'value'); } /** @@ -484,20 +459,20 @@ public function createQuery(string $name, array $fields, callable $beforeFields * Under normal circumstances, the selected fields (including `userErrors`, when requested) are returned as an array. * A `false` return value indicates a low-level communication failure. * - * All other issues should trigger a {@see ShopifyException}. + * All other issues should trigger a {@see ShopifyApiException}. * * @param Query|string $query * @param array|null $variables * @return mixed Typically an array with the same structure as the selection, or `null` for nonexistent nodes. - * @throws ShopifyException when the response looks unusual (i.e. an `errors` key is present, or a `data` key was not returned) - * @throws SessionNotFoundException if a session can’t be established + * @throws ShopifyApiException when the response looks unusual (i.e. an `errors` key is present, or a `data` key was not returned) + * @throws \RuntimeException if a session can't be established * @since 6.0.0 */ public function query(Query|string $query, ?array $variables = null): mixed { // An invalid session will cause everything to fail: - if ($this->getSession() === null) { - throw new SessionNotFoundException(Craft::t('shopify', 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.')); + if (!$this->connect()) { + throw new \RuntimeException(Craft::t('shopify', 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.')); } $payload = ['query' => (string)$query]; @@ -507,8 +482,7 @@ public function query(Query|string $query, ?array $variables = null): mixed } try { - $response = $this->getGqlClient()->query($payload); - $body = $response->getDecodedBody(); + $body = $this->getGqlClient()->query($payload); if (array_key_exists('errors', $body)) { $message = $body['errors']; @@ -518,42 +492,42 @@ public function query(Query|string $query, ?array $variables = null): mixed // Others need to be unpacked from an array: if (is_array($message)) { $message = $message[0]['message']; - // (Shopify also suggests that 400 errors may have a key like `query`, but we haven’t observed this!) + // (Shopify also suggests that 400 errors may have a key like `query`, but we haven't observed this!) } - throw new ShopifyException($message); + throw new ShopifyApiException($message); } // GraphQL responses are always nested inside a `data` key: if (!isset($body['data'])) { - throw new ShopifyException('No data was returned from the GraphQL query.'); + throw new ShopifyApiException('No data was returned from the GraphQL query.'); } $data = $body['data']; - // Queries and mutations have implicit “names” based on the procedure, which is where our data will be in the response. - // The name itself doesn’t matter (we are only sending one query or mutation at a time), so we can just unwrap the “first” item: + // Queries and mutations have implicit "names" based on the procedure, which is where our data will be in the response. + // The name itself doesn't matter (we are only sending one query or mutation at a time), so we can just unwrap the "first" item: $data = ArrayHelper::firstValue($data); // The query may have selected `userErrors`, so we should check and throw: if (!empty($data['userErrors'])) { Craft::error('A GraphQL response included `userErrors`: ' . join(', ', array_column($data['userErrors'], 'message')), __METHOD__); - throw new ShopifyException($data['userErrors'][0]['message']); + throw new ShopifyApiException($data['userErrors'][0]['message']); } return $data; - } catch (ClientExceptionInterface $e) { + } catch (ShopifyApiException $e) { // We only intercept communication-related exceptions, here. // Everything else (like a query or mutation issue) is allowed to bubble out so it can be reported to the user. Craft::error('Could not run GraphQL query: ' . $e->getMessage(), __METHOD__); // Re-throw as an API error: - throw new ShopifyException('An issue occurred while communicating with the Shopify API. Check the logs for more information.'); + throw new ShopifyApiException('An issue occurred while communicating with the Shopify API. Check the logs for more information.', 0, $e); } } /** - * Queries the data table for records of the specified type, optionally owned by one or more “parent” objects. + * Queries the data table for records of the specified type, optionally owned by one or more "parent" objects. * * @param string $type * @param string|false|null $parentId @@ -585,116 +559,61 @@ public function getShopifyDataByType(string $type, array|string|null|false $pare * Returns or sets up a GraphQL API client. * * @see query() - * @return Graphql - * @throws MissingArgumentException + * @return GraphqlClient * @since 6.0.0 */ - public function getGqlClient(): Graphql + public function getGqlClient(): GraphqlClient { if ($this->_gqlClient === null) { - $session = $this->getSession(); - - if (!$session) { + if (!$this->_accessToken) { throw new InvalidConfigException('Unable to initialize API session. Check that your API credentials are correct and that you have authorized the app.'); } - $this->_gqlClient = new Graphql($session->getShop(), $session->getAccessToken()); + $pluginSettings = Plugin::getInstance()->getSettings(); + + $this->_gqlClient = new GraphqlClient( + $pluginSettings->getHostName(true), + $this->_accessToken, + $pluginSettings->getApiVersion(), + ); } return $this->_gqlClient; } /** - * Returns or initializes a context + session. + * Ensures the service is initialized with a shop hostname and access token. + * + * Returns true if the plugin is authorized and ready to make API calls, false otherwise. * - * @return Session|null - * @throws MissingArgumentException + * @return bool + * @since 8.0 */ - public function getSession(): ?Session + public function connect(): bool { - $pluginSettings = Plugin::getInstance()->getSettings(); - - if ( - $this->_session === null && - ($pluginSettings->getClientId(true)) && - ($pluginSettings->getClientSecret(true)) - ) { - $this->initializeContext(); - - $hostName = $pluginSettings->getHostName(true); - $accessToken = $this->getAccessToken(shop: $hostName); - - // If there isn't an access token we can't create a session - if ($accessToken) { - $this->_session = new Session( - id: 'NA', - shop: $hostName, - isOnline: false, - state: 'NA' - ); - - $this->_session->setAccessToken($accessToken); // this is the most important part of the authentication - } + if ($this->_accessToken !== null) { + return true; } - return $this->_session; - } - - /** - * @return void - * @throws MissingArgumentException - * @throws \yii\base\Exception - * @since 7.0.0 - */ - public function initializeContext(): void - { $pluginSettings = Plugin::getInstance()->getSettings(); - /** @var MonologTarget $webLogTarget */ - $webLogTarget = Craft::$app->getLog()->targets['web']; - - $contextConfig = [ - 'apiKey' => $pluginSettings->getClientId(), - 'apiSecretKey' => $pluginSettings->getClientSecret(), - 'scopes' => $pluginSettings->getScopes(true), - // This `hostName` is different from the `shop` value used when creating a Session! - // Shopify wants a name for the host/environment that is *initiating* the API connection. - // Internally, they appear to use this for starting OAuth flows and creating webhooks (but we handle the latter, manually). - 'hostName' => !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost', - 'sessionStorage' => new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'), - 'apiVersion' => $pluginSettings->getApiVersion(), - 'isEmbeddedApp' => false, - 'logger' => $webLogTarget->getLogger(), - ]; - - if ($this->hasEventHandlers(self::EVENT_DEFINE_CONTEXT_CONFIG)) { - $event = new DefineContextConfigEvent(['config' => $contextConfig]); - $this->trigger(self::EVENT_DEFINE_CONTEXT_CONFIG, $event); - $contextConfig = $event->config; + if (!$pluginSettings->getClientId(true) || !$pluginSettings->getClientSecret(true)) { + return false; } - Context::initialize(...$contextConfig); - - Context::$HTTP_CLIENT_FACTORY = new class() extends HttpClientFactory { - public function client(): ClientInterface - { - // This is the default client, but we need to add the header for presentment prices - return new Client(['headers' => ['X-Shopify-Api-Features' => 'include-presentment-prices']]); - } - }; + $accessToken = $this->getAccessToken(shop: $pluginSettings->getHostName(true)); - if ($this->hasEventHandlers(self::EVENT_CONTEXT_INITIALIZED)) { - $this->trigger(self::EVENT_CONTEXT_INITIALIZED); + if ($accessToken) { + $this->_accessToken = $accessToken; } + + return $this->_accessToken !== null; } /** * @param string|null $code * @param string|null $shop * @return string|null - * @throws ClientExceptionInterface - * @throws UninitializedContextException - * @throws \JsonException * @since 7.0.0 */ public function getAccessToken(?string $code = null, ?string $shop = null, bool $forceRefresh = false): ?string @@ -708,17 +627,18 @@ public function getAccessToken(?string $code = null, ?string $shop = null, bool return null; } - $client = new Http($shop); - try { - $response = $client->post(OAuth::ACCESS_TOKEN_POST_PATH, [ - 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), - 'client_secret' => Plugin::getInstance()->getSettings()->getClientSecret(true), - 'code' => $code, - 'expiring' => 0, + $httpClient = new Client(); + $response = $httpClient->post('https://' . $shop . OAuthFlow::ACCESS_TOKEN_POST_PATH, [ + 'json' => [ + 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), + 'client_secret' => Plugin::getInstance()->getSettings()->getClientSecret(true), + 'code' => $code, + 'expiring' => 0, + ], ]); - $body = $response->getDecodedBody(); + $body = json_decode((string)$response->getBody(), true); if (!isset($body['access_token'])) { throw new \Exception('No access token returned from Shopify.'); @@ -791,8 +711,7 @@ public function getWebhooks(): Collection /** * @param string $id Shopify webhook subscription GID * @return bool - * @throws MissingArgumentException - * @throws ShopifyException + * @throws ShopifyApiException * @since 6.0.0 */ public function deleteWebhookById(string $id): bool @@ -820,7 +739,7 @@ public function deleteWebhookById(string $id): bool if (!$this->query($mutation, $variables)) { Craft::error(sprintf('No data was returned while deleting webhook %s', $id), __METHOD__); - throw new ShopifyException('The webhook may not have been deleted.'); + throw new ShopifyApiException('The webhook may not have been deleted.'); } return true; diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index cdb9e1db..ea1b8d00 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -23,7 +23,7 @@ use GraphQL\Mutation; use GraphQL\Variable; use Illuminate\Support\Collection; -use Shopify\Exception\ShopifyException; +use craft\shopify\exceptions\ShopifyApiException; use yii\base\InvalidConfigException; use yii\db\Exception; use yii\db\StaleObjectException; @@ -176,7 +176,7 @@ public function nextBulkOperation(): bool try { $bulkOpStatusResponse = Plugin::getInstance()->getApi()->query($bulkOpsStatusQuery); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { return false; } @@ -206,7 +206,7 @@ public function nextBulkOperation(): bool try { $data = Plugin::getInstance()->getApi()->query($mutation, ['query' => $bulkOperation->query]); - } catch (ShopifyException $e) { + } catch (ShopifyApiException $e) { // If there was an issue creating the operation that we haven’t accounted for, just mark it as completed: Craft::error('Could not start bulk operation: ' . $e->getMessage(), __METHOD__); diff --git a/src/webhooks/WebhookRegistry.php b/src/webhooks/WebhookRegistry.php new file mode 100644 index 00000000..9cb23766 --- /dev/null +++ b/src/webhooks/WebhookRegistry.php @@ -0,0 +1,68 @@ + + * @since 8.0.0 + */ +class WebhookRegistry +{ + private static array $registry = []; + + public static function addHandler(WebhookTopics $topic, object $handler): void + { + self::$registry[$topic->value] = $handler; + } + + public static function getHandler(WebhookTopics $topic): ?object + { + return self::$registry[$topic->value] ?? null; + } + + /** + * Validates the HMAC of an incoming webhook request and dispatches to the registered handler. + * + * @throws \RuntimeException on HMAC failure, missing headers, unknown topic, or missing handler. + */ + public static function process(array $headers, string $rawBody, string $secret): void + { + $get = static function(array $headers, string $key): string { + $value = $headers[$key] ?? $headers[strtolower($key)] ?? ''; + return is_array($value) ? ($value[0] ?? '') : (string)$value; + }; + + $topicValue = $get($headers, 'X-Shopify-Topic'); + $shop = $get($headers, 'X-Shopify-Shop-Domain'); + $hmac = $get($headers, 'X-Shopify-Hmac-SHA256'); + + if (!$topicValue || !$shop || !$hmac) { + throw new \RuntimeException('Missing required Shopify webhook headers.'); + } + + $expected = base64_encode(hash_hmac('sha256', $rawBody, $secret, true)); + if (!hash_equals($expected, $hmac)) { + throw new \RuntimeException('Webhook HMAC validation failed.'); + } + + $topic = WebhookTopics::tryFrom($topicValue); + if ($topic === null) { + throw new \RuntimeException("Unknown webhook topic: {$topicValue}"); + } + + $handler = self::getHandler($topic); + if (!$handler) { + throw new \RuntimeException("No handler registered for webhook topic: {$topicValue}"); + } + + $body = json_decode($rawBody, true) ?? []; + $handler->handle($topic, $shop, $body); + } +} diff --git a/src/webhooks/WebhookTopics.php b/src/webhooks/WebhookTopics.php new file mode 100644 index 00000000..9e510cfb --- /dev/null +++ b/src/webhooks/WebhookTopics.php @@ -0,0 +1,38 @@ + + * @since 8.0.0 + */ +enum WebhookTopics: string +{ + case ProductsCreate = 'products/create'; + case ProductsUpdate = 'products/update'; + case ProductsDelete = 'products/delete'; + case InventoryLevelsUpdate = 'inventory_levels/update'; + case InventoryItemsUpdate = 'inventory_items/update'; + case BulkOperationsFinish = 'bulk_operations/finish'; + case ShopUpdate = 'shop/update'; + + /** + * Returns the GraphQL WebhookSubscriptionTopic enum string for this topic. + * + * e.g. products/create → PRODUCTS_CREATE + */ + public function toGraphQLEnum(): string + { + return strtoupper(str_replace('/', '_', $this->value)); + } +} From 1f4efa8eb2b41be71efd6e7722ee8acbaf7d5b87 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 13 Jul 2026 16:35:53 +0100 Subject: [PATCH 34/65] fix composer lock --- composer.lock | 1085 ++++++++++++++++++++++--------------------------- 1 file changed, 496 insertions(+), 589 deletions(-) diff --git a/composer.lock b/composer.lock index 5475b716..14f1ec67 100644 --- a/composer.lock +++ b/composer.lock @@ -538,16 +538,16 @@ }, { "name": "craftcms/cms", - "version": "5.10.8.1", + "version": "5.10.10", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "dc39613556a054f7674a3bdccdb813b8b7e28b34" + "reference": "d4c5f814e59de56a3124776da6b3c399cb28d64f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/dc39613556a054f7674a3bdccdb813b8b7e28b34", - "reference": "dc39613556a054f7674a3bdccdb813b8b7e28b34", + "url": "https://api.github.com/repos/craftcms/cms/zipball/d4c5f814e59de56a3124776da6b3c399cb28d64f", + "reference": "d4c5f814e59de56a3124776da6b3c399cb28d64f", "shasum": "" }, "require": { @@ -598,7 +598,7 @@ "theiconic/name-parser": "^1.2", "twig/twig": "~3.27.0", "voku/portable-ascii": "^2.0", - "web-auth/webauthn-lib": "~5.2.4", + "web-auth/webauthn-lib": "~5.3.5", "yiisoft/yii2": "~2.0.55.0", "yiisoft/yii2-debug": "~2.1.27.0", "yiisoft/yii2-queue": "~2.3.2", @@ -664,7 +664,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-06-23T15:45:02+00:00" + "time": "2026-07-08T22:13:52+00:00" }, { "name": "craftcms/plugin-installer", @@ -763,16 +763,16 @@ }, { "name": "craftcms/url-validator", - "version": "1.0.0", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/craftcms/url-validator.git", - "reference": "75b44bc4d3f89feb9410b85d385f01210edd5eb1" + "reference": "3918c21317a856d6313b3fbf40d70ce013cdd715" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/url-validator/zipball/75b44bc4d3f89feb9410b85d385f01210edd5eb1", - "reference": "75b44bc4d3f89feb9410b85d385f01210edd5eb1", + "url": "https://api.github.com/repos/craftcms/url-validator/zipball/3918c21317a856d6313b3fbf40d70ce013cdd715", + "reference": "3918c21317a856d6313b3fbf40d70ce013cdd715", "shasum": "" }, "require": { @@ -813,9 +813,9 @@ ], "support": { "issues": "https://github.com/craftcms/url-validator/issues", - "source": "https://github.com/craftcms/url-validator/tree/1.0.0" + "source": "https://github.com/craftcms/url-validator/tree/1.1.0" }, - "time": "2026-06-15T17:29:09+00:00" + "time": "2026-07-06T20:35:29+00:00" }, { "name": "creocoder/yii2-nested-sets", @@ -1430,22 +1430,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.13.1", + "version": "7.14.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" + "reference": "6b1d2429a2c312474c523aa9017fba0c07b5f4a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", - "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/6b1d2429a2c312474c523aa9017fba0c07b5f4a0", + "reference": "6b1d2429a2c312474c523aa9017fba0c07b5f4a0", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.3", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.12.5", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1457,7 +1457,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", + "guzzle/client-integration-tests": "3.0.3", "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", @@ -1538,7 +1538,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.13.1" + "source": "https://github.com/guzzle/guzzle/tree/7.14.1" }, "funding": [ { @@ -1554,20 +1554,20 @@ "type": "tidelift" } ], - "time": "2026-06-29T20:14:18+00:00" + "time": "2026-07-13T01:32:54+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -1622,7 +1622,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1638,20 +1638,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.3", + "version": "2.12.5", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/9365d578a9fd1552ad6ca9c3cb530708526feb09", + "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09", "shasum": "" }, "require": { @@ -1741,7 +1741,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.3" + "source": "https://github.com/guzzle/psr7/tree/2.12.5" }, "funding": [ { @@ -1757,7 +1757,7 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:21:08+00:00" + "time": "2026-07-13T01:27:20+00:00" }, { "name": "illuminate/collections", @@ -2209,31 +2209,31 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.2.2", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", - "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-zlib": "*", - "php-64bit": "^8.3" + "php-64bit": "^8.2" }, "require-dev": { "brianium/paratest": "^7.7", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.86", + "friendsofphp/php-cs-fixer": "^3.16", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^12.0", + "phpunit/phpunit": "^11.0", "vimeo/psalm": "^6.0" }, "suggest": { @@ -2275,7 +2275,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" }, "funding": [ { @@ -2283,7 +2283,7 @@ "type": "github" } ], - "time": "2026-04-11T18:38:28+00:00" + "time": "2025-01-27T12:07:53+00:00" }, { "name": "markbaker/complex", @@ -3051,16 +3051,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "5.8.0", + "version": "5.9.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb" + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/01964d92536edf1a3a874b9580a52824bebf6fbb", - "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "shasum": "" }, "require": { @@ -3082,7 +3082,7 @@ "maennchen/zipstream-php": "^2.1 || ^3.0", "markbaker/complex": "^3.0", "markbaker/matrix": "^3.0", - "php": "^8.1", + "php": "^8.2", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { @@ -3096,7 +3096,7 @@ "phpstan/phpstan": "^1.1 || ^2.0", "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.5", + "phpunit/phpunit": "^10.5 || ^11.0", "squizlabs/php_codesniffer": "^3.7", "tecnickcom/tcpdf": "^6.5" }, @@ -3154,22 +3154,22 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.8.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" }, - "time": "2026-06-07T03:51:10+00:00" + "time": "2026-07-12T19:17:39+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -3201,9 +3201,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "pixelandtonic/graphql-php", @@ -4563,25 +4563,24 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.1.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/security-http": "<7.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -4590,14 +4589,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/error-handler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^7.4|^8.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4625,7 +4624,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -4645,7 +4644,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T12:28:30+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6331,34 +6330,35 @@ }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6397,7 +6397,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -6417,7 +6417,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "symfony/translation", @@ -6602,21 +6602,22 @@ }, { "name": "symfony/type-info", - "version": "v8.1.0", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/container": "^1.1|^2.0" + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { "phpstan/phpdoc-parser": "<1.30" @@ -6660,7 +6661,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.1.0" + "source": "https://github.com/symfony/type-info/tree/v7.4.9" }, "funding": [ { @@ -6680,7 +6681,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-04-22T15:21:55+00:00" }, { "name": "symfony/uid", @@ -7198,16 +7199,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.2.6", + "version": "5.3.5", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "0785f55f242c1cc026ec24a9c8653eac59fe3493" + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/0785f55f242c1cc026ec24a9c8653eac59fe3493", - "reference": "0785f55f242c1cc026ec24a9c8653eac59fe3493", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { @@ -7215,18 +7216,18 @@ "ext-openssl": "*", "paragonie/constant_time_encoding": "^2.6|^3.0", "php": ">=8.2", - "phpdocumentor/reflection-docblock": "^5.3", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "spomky-labs/cbor-php": "^3.0", "spomky-labs/pki-framework": "^1.0", - "symfony/clock": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "web-auth/cose-lib": "^4.2.3" }, "suggest": { @@ -7268,7 +7269,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.6" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" }, "funding": [ { @@ -7280,7 +7281,7 @@ "type": "patreon" } ], - "time": "2026-03-23T22:13:50+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { "name": "webmozart/assert", @@ -8541,21 +8542,22 @@ }, { "name": "craftcms/feed-me", - "version": "6.13.0.1", + "version": "6.14.0", "source": { "type": "git", "url": "https://github.com/craftcms/feed-me.git", - "reference": "9a2e37bb27045e17dfa91e6ef67370b19e06fbed" + "reference": "8099a337342ebab0f46e0dbf23a544cb2efcb017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/feed-me/zipball/9a2e37bb27045e17dfa91e6ef67370b19e06fbed", - "reference": "9a2e37bb27045e17dfa91e6ef67370b19e06fbed", + "url": "https://api.github.com/repos/craftcms/feed-me/zipball/8099a337342ebab0f46e0dbf23a544cb2efcb017", + "reference": "8099a337342ebab0f46e0dbf23a544cb2efcb017", "shasum": "" }, "require": { "cakephp/utility": "^5.0.0", - "craftcms/cms": "^5.8.0", + "craftcms/cms": "^5.10.9", + "craftcms/url-validator": "^1.0", "jakeasmith/http_build_url": "^1.0", "league/csv": "^9.0", "nesbot/carbon": "^2.10|^3.0.0", @@ -8607,7 +8609,7 @@ "rss": "https://github.com/craftcms/feed-me/commits/master.atom", "source": "https://github.com/craftcms/feed-me" }, - "time": "2026-05-28T18:56:10+00:00" + "time": "2026-07-08T22:22:40+00:00" }, { "name": "craftcms/html-field", @@ -9215,20 +9217,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -9267,9 +9268,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -9519,16 +9520,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "13.0.2", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2ea1bcdad040326c02edd6519cc9d1c5a9f6c87e" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2ea1bcdad040326c02edd6519cc9d1c5a9f6c87e", - "reference": "2ea1bcdad040326c02edd6519cc9d1c5a9f6c87e", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { @@ -9536,16 +9537,18 @@ "ext-libxml": "*", "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", - "php": ">=8.4", - "phpunit/php-text-template": "^6.0", - "sebastian/complexity": "^6.0", - "sebastian/environment": "^9.0", - "sebastian/lines-of-code": "^5.0", - "sebastian/version": "^7.0", - "theseer/tokenizer": "^2.0.1" + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -9554,7 +9557,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "13.0.x-dev" + "dev-main": "11.0.x-dev" } }, "autoload": { @@ -9583,7 +9586,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/13.0.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -9603,32 +9606,32 @@ "type": "tidelift" } ], - "time": "2026-04-01T14:12:38+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "7.0.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -9656,7 +9659,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { @@ -9676,28 +9679,28 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:33:26+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", - "version": "7.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", - "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" @@ -9705,7 +9708,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -9732,52 +9735,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", - "type": "tidelift" } ], - "time": "2026-02-06T04:34:47+00:00" + "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", - "version": "6.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9804,52 +9795,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", - "type": "tidelift" } ], - "time": "2026-02-06T04:36:37+00:00" + "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", - "version": "9.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", - "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "9.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9876,77 +9855,69 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", - "type": "tidelift" } ], - "time": "2026-02-06T04:37:53+00:00" + "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", - "version": "13.0.6", + "version": "11.5.56", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9e426f7282c313c9138eeb9f25461e1a6be1e647" + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9e426f7282c313c9138eeb9f25461e1a6be1e647", - "reference": "9e426f7282c313c9138eeb9f25461e1a6be1e647", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.4.1", - "phpunit/php-code-coverage": "^13.0.1", - "phpunit/php-file-iterator": "^7.0.0", - "phpunit/php-invoker": "^7.0.0", - "phpunit/php-text-template": "^6.0.0", - "phpunit/php-timer": "^9.0.0", - "sebastian/cli-parser": "^5.0.0", - "sebastian/comparator": "^8.0.0", - "sebastian/diff": "^8.0.0", - "sebastian/environment": "^9.1.0", - "sebastian/exporter": "^8.0.0", - "sebastian/global-state": "^9.0.0", - "sebastian/object-enumerator": "^8.0.0", - "sebastian/recursion-context": "^8.0.0", - "sebastian/type": "^7.0.0", - "sebastian/version": "^7.0.0", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, "bin": [ "phpunit" ], "type": "library", "extra": { "branch-alias": { - "dev-main": "13.0-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -9978,7 +9949,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" }, "funding": [ { @@ -9986,7 +9957,7 @@ "type": "other" } ], - "time": "2026-03-31T06:44:39+00:00" + "time": "2026-07-06T14:52:39+00:00" }, { "name": "psy/psysh", @@ -10128,28 +10099,28 @@ }, { "name": "sebastian/cli-parser", - "version": "5.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -10173,51 +10144,152 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", - "type": "tidelift" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "time": "2026-02-06T04:39:44+00:00" + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", - "version": "8.2.1", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", - "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.4", - "sebastian/diff": "^8.3", - "sebastian/exporter": "^8.0.3" + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^11.4" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -10225,7 +10297,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.2-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -10265,7 +10337,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/8.2.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -10285,33 +10357,33 @@ "type": "tidelift" } ], - "time": "2026-05-21T04:46:40+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", - "version": "6.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", - "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -10335,53 +10407,41 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", - "type": "tidelift" } ], - "time": "2026-02-06T04:41:32+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", - "version": "8.3.0", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.3-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10414,47 +10474,35 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", - "type": "tidelift" } ], - "time": "2026-05-15T04:58:09+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", - "version": "9.3.2", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.1.11" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -10462,7 +10510,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.3-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -10490,7 +10538,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { @@ -10510,34 +10558,34 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:41:38+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", - "version": "8.1.0", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.4", - "sebastian/recursion-context": "^8.0" + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -10580,7 +10628,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, "funding": [ { @@ -10600,35 +10648,35 @@ "type": "tidelift" } ], - "time": "2026-05-21T11:50:56+00:00" + "time": "2025-09-24T06:12:51+00:00" }, { "name": "sebastian/global-state", - "version": "9.0.1", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", - "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=8.4", - "sebastian/object-reflector": "^6.0", - "sebastian/recursion-context": "^8.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^13.1.13" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "9.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10654,53 +10702,41 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", - "type": "tidelift" } ], - "time": "2026-06-01T15:11:33+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", - "version": "5.0.1", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "nikic/php-parser": "^5.7.0", - "php": ">=8.4" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -10724,54 +10760,42 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", - "type": "tidelift" } ], - "time": "2026-05-19T16:23:37+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", - "version": "8.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=8.4", - "sebastian/object-reflector": "^6.0", - "sebastian/recursion-context": "^8.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10794,52 +10818,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", - "type": "tidelift" } ], - "time": "2026-02-06T04:46:36+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", - "version": "6.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -10862,52 +10874,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", - "type": "tidelift" } ], - "time": "2026-02-06T04:47:13+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", - "version": "8.0.0", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10938,7 +10938,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { @@ -10958,32 +10958,32 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:51:28+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", - "version": "7.0.1", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fee0309275847fefd7636167085e379c1dbf6990" + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", - "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -11007,7 +11007,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "funding": [ { @@ -11027,29 +11027,29 @@ "type": "tidelift" } ], - "time": "2026-05-20T06:49:11+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", - "version": "7.0.0", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -11073,27 +11073,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/version", - "type": "tidelift" } ], - "time": "2026-02-06T04:52:52+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { "name": "seld/jsonlint", @@ -11213,27 +11201,28 @@ }, { "name": "symfony/browser-kit", - "version": "v8.1.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5" + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5", - "reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bb28e8761a6c33975972948010f00d4a10f0a634", + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/dom-crawler": "^7.4|^8.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/dom-crawler": "^6.4|^7.0|^8.0" }, "require-dev": { - "symfony/css-selector": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0" + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -11261,7 +11250,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v8.1.1" + "source": "https://github.com/symfony/browser-kit/tree/v7.4.14" }, "funding": [ { @@ -11281,53 +11270,51 @@ "type": "tidelift" } ], - "time": "2026-06-09T10:54:51+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/console", - "version": "v8.1.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php85": "^1.32", + "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4.6|^8.0.6" + "symfony/string": "^7.2|^8.0" }, "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/event-dispatcher": "<8.1" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/event-dispatcher": "^8.1", - "symfony/filesystem": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -11361,7 +11348,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.1" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -11381,27 +11368,27 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:55:20+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/finder", - "version": "v8.1.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", - "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^7.4|^8.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -11429,87 +11416,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-27T09:05:56+00:00" - }, - { - "name": "symfony/polyfill-php85", - "version": "v1.38.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -11529,7 +11436,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symplify/easy-coding-standard", @@ -11589,23 +11496,23 @@ }, { "name": "theseer/tokenizer", - "version": "2.0.1", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^8.1" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { @@ -11627,7 +11534,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -11635,20 +11542,20 @@ "type": "github" } ], - "time": "2025-12-08T11:19:18+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -11707,7 +11614,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -11719,7 +11626,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" } ], "aliases": [], @@ -11735,5 +11642,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 64b5159f5502659292b9718e81331ed36d436c3e Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 13 Jul 2026 16:35:58 +0100 Subject: [PATCH 35/65] fix cs --- src/Plugin.php | 2 +- src/auth/OAuthFlow.php | 1 - src/console/controllers/ApiController.php | 2 +- src/controllers/WebhooksController.php | 3 +-- src/models/Settings.php | 2 +- src/services/Api.php | 10 +++++----- src/services/BulkOperations.php | 2 +- 7 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index 57e3e5eb..01ad71ff 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -54,10 +54,10 @@ use craft\shopify\services\Store; use craft\shopify\utilities\Sync; use craft\shopify\web\twig\CraftVariableBehavior; +use craft\shopify\webhooks\WebhookRegistry; use craft\web\twig\variables\CraftVariable; use craft\web\UrlManager; use GraphQL\Query as GqlQuery; -use craft\shopify\webhooks\WebhookRegistry; use yii\base\Event; use yii\base\InvalidConfigException; diff --git a/src/auth/OAuthFlow.php b/src/auth/OAuthFlow.php index 5b0f7e7b..4d5dbc05 100644 --- a/src/auth/OAuthFlow.php +++ b/src/auth/OAuthFlow.php @@ -8,7 +8,6 @@ namespace craft\shopify\auth; use craft\helpers\StringHelper; -use craft\helpers\UrlHelper; use craft\shopify\Plugin; /** diff --git a/src/console/controllers/ApiController.php b/src/console/controllers/ApiController.php index 9998a692..7572505e 100644 --- a/src/console/controllers/ApiController.php +++ b/src/console/controllers/ApiController.php @@ -10,8 +10,8 @@ use Craft; use craft\console\Controller; use craft\helpers\Console; -use craft\shopify\Plugin; use craft\shopify\exceptions\ShopifyApiException; +use craft\shopify\Plugin; use yii\console\ExitCode; /** diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 8fa36e19..6777deba 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -9,12 +9,11 @@ use Craft; use craft\helpers\Html; +use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\Plugin; -use craft\shopify\webhooks\WebhookTopics; use craft\web\Controller; use GraphQL\Query; use GraphQL\Variable; -use craft\shopify\exceptions\ShopifyApiException; use yii\web\ConflictHttpException; use yii\web\Response as YiiResponse; diff --git a/src/models/Settings.php b/src/models/Settings.php index 064edcea..830d196d 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -15,9 +15,9 @@ use craft\helpers\UrlHelper; use craft\shopify\elements\Product; use craft\shopify\enums\ApiVersion; +use craft\shopify\helpers\ShopifyHelper; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; -use craft\shopify\helpers\ShopifyHelper; /** * Shopify Settings model. diff --git a/src/services/Api.php b/src/services/Api.php index bf114f86..f9dd55f4 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -12,20 +12,20 @@ use craft\helpers\ArrayHelper; use craft\helpers\Json; use craft\helpers\StringHelper; +use craft\shopify\auth\OAuthFlow; +use craft\shopify\clients\GraphqlClient; +use craft\shopify\enums\ApiVersion; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; +use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; +use craft\shopify\webhooks\WebhookTopics; use GraphQL\Mutation; use GraphQL\Query; use GraphQL\QueryBuilder\QueryBuilder; use GraphQL\Variable; -use craft\shopify\auth\OAuthFlow; -use craft\shopify\clients\GraphqlClient; -use craft\shopify\enums\ApiVersion; -use craft\shopify\exceptions\ShopifyApiException; -use craft\shopify\webhooks\WebhookTopics; use GuzzleHttp\Client; use Illuminate\Support\Collection; use yii\base\InvalidConfigException; diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php index ea1b8d00..e6c1dd86 100644 --- a/src/services/BulkOperations.php +++ b/src/services/BulkOperations.php @@ -15,6 +15,7 @@ use craft\helpers\Queue; use craft\shopify\db\Table; use craft\shopify\enums\BulkOperationStatus; +use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\jobs\ProcessBulkOperationData; use craft\shopify\models\BulkOperation; use craft\shopify\Plugin; @@ -23,7 +24,6 @@ use GraphQL\Mutation; use GraphQL\Variable; use Illuminate\Support\Collection; -use craft\shopify\exceptions\ShopifyApiException; use yii\base\InvalidConfigException; use yii\db\Exception; use yii\db\StaleObjectException; From 8e03588b7d6a73d340dd2c25dddbcb3dfaf6736e Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 14 Jul 2026 08:21:38 +0100 Subject: [PATCH 36/65] Fix webhook topics usage --- CHANGELOG-WIP.md | 7 ++++--- src/Plugin.php | 2 +- src/controllers/WebhooksController.php | 4 ++-- src/{webhooks => enums}/WebhookTopics.php | 2 +- src/handlers/Webhook.php | 2 +- src/services/Api.php | 25 +++++++++-------------- src/webhooks/WebhookRegistry.php | 2 ++ 7 files changed, 21 insertions(+), 23 deletions(-) rename src/{webhooks => enums}/WebhookTopics.php (96%) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 7f2d46eb..583b7f66 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -30,19 +30,19 @@ - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. - Added `craft\shopify\services\Api::getShopLocalesGql()`. - Added `craft\shopify\services\Api::connect()`. +- Added `craft\shopify\services\Api::getWebhookTopics()`. - Added `craft\shopify\auth\OAuthFlow`. - Added `craft\shopify\clients\GraphqlClient`. - Added `craft\shopify\enums\ApiVersion`. +- Added `craft\shopify\enums\WebhookTopics`. - Added `craft\shopify\exceptions\InvalidOAuthException`. - Added `craft\shopify\exceptions\ShopifyApiException`. - Added `craft\shopify\helpers\ShopifyHelper`. - Added `craft\shopify\webhooks\WebhookRegistry`. -- Added `craft\shopify\webhooks\WebhookTopics`. - `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. -- `craft\shopify\services\Api::WEBHOOK_TOPICS` now contains `craft\shopify\webhooks\WebhookTopics` enum cases instead of plain strings. -- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\webhooks\WebhookTopics` enum instead of a string. +- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. - API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. - Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`. - Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`. @@ -52,6 +52,7 @@ - Deprecated `craft\shopify\services\Products::syncProductByShopifyId()`. Use `syncProductByShopifyGid()` instead. - Removed `craft\shopify\services\Api::getSession()`. Use `connect()` instead. - Removed `craft\shopify\services\Api::initializeContext()`. +- Removed `craft\shopify\services\Api::WEBHOOK_TOPICS`. Use `getWebhookTopics()` instead. ### System diff --git a/src/Plugin.php b/src/Plugin.php index 01ad71ff..81122bdd 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -163,7 +163,7 @@ public function init() ->onRemove(self::PC_PATH_PRODUCT_FIELD_LAYOUTS, [$productsService, 'handleDeletedFieldLayout']); // Globally register shopify webhooks registry event handlers - foreach ($this->getApi()::WEBHOOK_TOPICS as $topic) { + foreach ($this->getApi()->getWebhookTopics() as $topic) { WebhookRegistry::addHandler($topic, new Webhook()); } } diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php index 6777deba..f96cd0a3 100644 --- a/src/controllers/WebhooksController.php +++ b/src/controllers/WebhooksController.php @@ -57,7 +57,7 @@ public function actionEdit(): YiiResponse $requiredTopics = array_flip(array_map( fn($t) => $t->toGraphQLEnum(), - $api::WEBHOOK_TOPICS, + $api->getWebhookTopics(), )); foreach ($webhooks as $hook) { @@ -187,7 +187,7 @@ public function actionCreate(): ?YiiResponse $errors = []; // Check each required topic and create missing subscriptions: - foreach ($api::WEBHOOK_TOPICS as $topic) { + foreach ($api->getWebhookTopics() as $topic) { // Is there at least one webhook with this topic? if ($webhooks->contains('topic', $topic->toGraphQLEnum())) { continue; diff --git a/src/webhooks/WebhookTopics.php b/src/enums/WebhookTopics.php similarity index 96% rename from src/webhooks/WebhookTopics.php rename to src/enums/WebhookTopics.php index 9e510cfb..bd1f1031 100644 --- a/src/webhooks/WebhookTopics.php +++ b/src/enums/WebhookTopics.php @@ -5,7 +5,7 @@ * @license https://craftcms.github.io/license/ */ -namespace craft\shopify\webhooks; +namespace craft\shopify\enums; /** * Shopify webhook topic constants. diff --git a/src/handlers/Webhook.php b/src/handlers/Webhook.php index de5c2e11..311c04fb 100644 --- a/src/handlers/Webhook.php +++ b/src/handlers/Webhook.php @@ -8,7 +8,7 @@ namespace craft\shopify\handlers; use craft\shopify\Plugin; -use craft\shopify\webhooks\WebhookTopics; +use craft\shopify\enums\WebhookTopics; /** * Webhook handler. diff --git a/src/services/Api.php b/src/services/Api.php index f9dd55f4..f7313da0 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -21,7 +21,7 @@ use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; -use craft\shopify\webhooks\WebhookTopics; +use craft\shopify\enums\WebhookTopics; use GraphQL\Mutation; use GraphQL\Query; use GraphQL\QueryBuilder\QueryBuilder; @@ -40,20 +40,6 @@ */ class Api extends Component { - /** - * @var string[] - * @since 6.0.0 - */ - public const WEBHOOK_TOPICS = [ - WebhookTopics::ProductsCreate, - WebhookTopics::ProductsUpdate, - WebhookTopics::ProductsDelete, - WebhookTopics::InventoryLevelsUpdate, - WebhookTopics::InventoryItemsUpdate, - WebhookTopics::BulkOperationsFinish, - WebhookTopics::ShopUpdate, - ]; - /** * @since 7.0.0 */ @@ -90,6 +76,15 @@ public function getSupportedApiVersions(): array return array_column(ApiVersion::cases(), 'value'); } + /** + * @return WebhookTopics[] + * @since 8.0.0 + */ + public function getWebhookTopics(): array + { + return WebhookTopics::cases(); + } + /** * @return Query * @since 6.0.0 diff --git a/src/webhooks/WebhookRegistry.php b/src/webhooks/WebhookRegistry.php index 9cb23766..02a37ed9 100644 --- a/src/webhooks/WebhookRegistry.php +++ b/src/webhooks/WebhookRegistry.php @@ -7,6 +7,8 @@ namespace craft\shopify\webhooks; +use craft\shopify\enums\WebhookTopics; + /** * Registry for Shopify webhook handlers. * From 4af7f55871e30f2e2b3b230ddf916e945d21caa7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 14 Jul 2026 08:21:54 +0100 Subject: [PATCH 37/65] fix cs --- src/handlers/Webhook.php | 2 +- src/services/Api.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handlers/Webhook.php b/src/handlers/Webhook.php index 311c04fb..ddd94bd9 100644 --- a/src/handlers/Webhook.php +++ b/src/handlers/Webhook.php @@ -7,8 +7,8 @@ namespace craft\shopify\handlers; -use craft\shopify\Plugin; use craft\shopify\enums\WebhookTopics; +use craft\shopify\Plugin; /** * Webhook handler. diff --git a/src/services/Api.php b/src/services/Api.php index f7313da0..f3743f4f 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -15,13 +15,13 @@ use craft\shopify\auth\OAuthFlow; use craft\shopify\clients\GraphqlClient; use craft\shopify\enums\ApiVersion; +use craft\shopify\enums\WebhookTopics; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; use craft\shopify\records\ShopifyData; -use craft\shopify\enums\WebhookTopics; use GraphQL\Mutation; use GraphQL\Query; use GraphQL\QueryBuilder\QueryBuilder; From 8229aa19a2abecb936e7baa4b23db35c6dad4b2a Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 14 Jul 2026 13:41:25 +0100 Subject: [PATCH 38/65] =?UTF-8?q?Use=20Craft=E2=80=99s=20create=20guzzle?= =?UTF-8?q?=20client=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 3 ++- composer.lock | 22 +++++++++++----------- src/clients/GraphqlClient.php | 3 ++- src/services/Api.php | 3 +-- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 374099b4..35792c8b 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "require": { "php": "^8.2", "carnage/php-graphql-client": "^1.14", - "craftcms/cms": "^5.10.7" + "craftcms/cms": "^5.10.7", + "guzzlehttp/guzzle": "^7.2" }, "require-dev": { "codeception/codeception": "^5.0.11", diff --git a/composer.lock b/composer.lock index 14f1ec67..35c65c99 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d3aea8b0ac73aaddae88a754879fc7b5", + "content-hash": "8a28934dc4bbbf935e64de127ce7e47e", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7926,16 +7926,16 @@ }, { "name": "cakephp/core", - "version": "5.3.6", + "version": "5.3.7", "source": { "type": "git", "url": "https://github.com/cakephp/core.git", - "reference": "9c458b0e9322ec88bc4c758b33cde6a0abf49d12" + "reference": "ffd32d2deab2076b31b18d8239cd6b8ed8c17a05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/core/zipball/9c458b0e9322ec88bc4c758b33cde6a0abf49d12", - "reference": "9c458b0e9322ec88bc4c758b33cde6a0abf49d12", + "url": "https://api.github.com/repos/cakephp/core/zipball/ffd32d2deab2076b31b18d8239cd6b8ed8c17a05", + "reference": "ffd32d2deab2076b31b18d8239cd6b8ed8c17a05", "shasum": "" }, "require": { @@ -7989,20 +7989,20 @@ "issues": "https://github.com/cakephp/cakephp/issues", "source": "https://github.com/cakephp/core" }, - "time": "2026-05-15T03:31:14+00:00" + "time": "2026-06-07T21:44:48+00:00" }, { "name": "cakephp/utility", - "version": "5.3.6", + "version": "5.3.7", "source": { "type": "git", "url": "https://github.com/cakephp/utility.git", - "reference": "4c703a010b9d955fed44731669e35d3043425cc7" + "reference": "0ce9d1fb015d3389d0170fc438f2a3b8374fe389" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/utility/zipball/4c703a010b9d955fed44731669e35d3043425cc7", - "reference": "4c703a010b9d955fed44731669e35d3043425cc7", + "url": "https://api.github.com/repos/cakephp/utility/zipball/0ce9d1fb015d3389d0170fc438f2a3b8374fe389", + "reference": "0ce9d1fb015d3389d0170fc438f2a3b8374fe389", "shasum": "" }, "require": { @@ -8053,7 +8053,7 @@ "issues": "https://github.com/cakephp/cakephp/issues", "source": "https://github.com/cakephp/utility" }, - "time": "2026-05-21T19:38:13+00:00" + "time": "2026-06-07T21:44:48+00:00" }, { "name": "codeception/codeception", diff --git a/src/clients/GraphqlClient.php b/src/clients/GraphqlClient.php index 5e8fc0cb..255674cf 100644 --- a/src/clients/GraphqlClient.php +++ b/src/clients/GraphqlClient.php @@ -7,6 +7,7 @@ namespace craft\shopify\clients; +use Craft; use craft\shopify\exceptions\ShopifyApiException; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; @@ -26,7 +27,7 @@ public function __construct( string $accessToken, private string $apiVersion, ) { - $this->_client = new Client([ + $this->_client = Craft::createGuzzleClient([ 'base_uri' => "https://{$shop}", 'headers' => [ 'X-Shopify-Access-Token' => $accessToken, diff --git a/src/services/Api.php b/src/services/Api.php index f3743f4f..4b95b224 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -26,7 +26,6 @@ use GraphQL\Query; use GraphQL\QueryBuilder\QueryBuilder; use GraphQL\Variable; -use GuzzleHttp\Client; use Illuminate\Support\Collection; use yii\base\InvalidConfigException; @@ -623,7 +622,7 @@ public function getAccessToken(?string $code = null, ?string $shop = null, bool } try { - $httpClient = new Client(); + $httpClient = Craft::createGuzzleClient(); $response = $httpClient->post('https://' . $shop . OAuthFlow::ACCESS_TOKEN_POST_PATH, [ 'json' => [ 'client_id' => Plugin::getInstance()->getSettings()->getClientId(true), From 7e00a9504de7bbbb0a2f2bf1c0ffe85e291f1f2c Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 14 Jul 2026 13:51:58 +0100 Subject: [PATCH 39/65] exceptions --- CHANGELOG-WIP.md | 1 + src/clients/GraphqlClient.php | 6 +++--- .../ShopifyApiCommunicationException.php | 19 +++++++++++++++++++ src/services/Api.php | 11 +++++------ 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/exceptions/ShopifyApiCommunicationException.php diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 583b7f66..2d7a61eb 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -36,6 +36,7 @@ - Added `craft\shopify\enums\ApiVersion`. - Added `craft\shopify\enums\WebhookTopics`. - Added `craft\shopify\exceptions\InvalidOAuthException`. +- Added `craft\shopify\exceptions\ShopifyApiCommunicationException`. - Added `craft\shopify\exceptions\ShopifyApiException`. - Added `craft\shopify\helpers\ShopifyHelper`. - Added `craft\shopify\webhooks\WebhookRegistry`. diff --git a/src/clients/GraphqlClient.php b/src/clients/GraphqlClient.php index 255674cf..0c1d7b11 100644 --- a/src/clients/GraphqlClient.php +++ b/src/clients/GraphqlClient.php @@ -8,7 +8,7 @@ namespace craft\shopify\clients; use Craft; -use craft\shopify\exceptions\ShopifyApiException; +use craft\shopify\exceptions\ShopifyApiCommunicationException; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; @@ -42,7 +42,7 @@ public function __construct( * * @param array $data An array with at minimum a `query` key, optionally `variables`. * @return array The decoded response body. - * @throws ShopifyApiException on HTTP or communication failure. + * @throws ShopifyApiCommunicationException on HTTP or communication failure. */ public function query(array $data, array $extraHeaders = []): array { @@ -59,7 +59,7 @@ public function query(array $data, array $extraHeaders = []): array return json_decode((string)$response->getBody(), true) ?? []; } catch (GuzzleException $e) { - throw new ShopifyApiException($e->getMessage(), $e->getCode(), $e); + throw new ShopifyApiCommunicationException($e->getMessage(), $e->getCode(), $e); } } } diff --git a/src/exceptions/ShopifyApiCommunicationException.php b/src/exceptions/ShopifyApiCommunicationException.php new file mode 100644 index 00000000..9efb917f --- /dev/null +++ b/src/exceptions/ShopifyApiCommunicationException.php @@ -0,0 +1,19 @@ + + * @since 8.0.0 + */ +class ShopifyApiCommunicationException extends ShopifyApiException +{ +} diff --git a/src/services/Api.php b/src/services/Api.php index 4b95b224..b7950c4c 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -18,6 +18,7 @@ use craft\shopify\enums\WebhookTopics; use craft\shopify\events\DefineGqlFieldsEvent; use craft\shopify\events\DefineGqlQueryArgumentsEvent; +use craft\shopify\exceptions\ShopifyApiCommunicationException; use craft\shopify\exceptions\ShopifyApiException; use craft\shopify\Plugin; use craft\shopify\records\AccessToken; @@ -451,14 +452,12 @@ public function createQuery(string $name, array $fields, callable $beforeFields * If you need to control how a response is unpacked, use {@see getGqlClient()} directly. * * Under normal circumstances, the selected fields (including `userErrors`, when requested) are returned as an array. - * A `false` return value indicates a low-level communication failure. - * - * All other issues should trigger a {@see ShopifyApiException}. * * @param Query|string $query * @param array|null $variables * @return mixed Typically an array with the same structure as the selection, or `null` for nonexistent nodes. - * @throws ShopifyApiException when the response looks unusual (i.e. an `errors` key is present, or a `data` key was not returned) + * @throws ShopifyApiException when the response looks unusual (i.e. an `errors` key is present, a `data` key was not returned, or `userErrors` was populated) + * @throws ShopifyApiCommunicationException on a low-level communication failure * @throws \RuntimeException if a session can't be established * @since 6.0.0 */ @@ -510,12 +509,12 @@ public function query(Query|string $query, ?array $variables = null): mixed } return $data; - } catch (ShopifyApiException $e) { + } catch (ShopifyApiCommunicationException $e) { // We only intercept communication-related exceptions, here. // Everything else (like a query or mutation issue) is allowed to bubble out so it can be reported to the user. Craft::error('Could not run GraphQL query: ' . $e->getMessage(), __METHOD__); - // Re-throw as an API error: + // Re-throw as a generic API error: throw new ShopifyApiException('An issue occurred while communicating with the Shopify API. Check the logs for more information.', 0, $e); } } From 1ec2fc8d6c93566dbd330c7c2012bda0c8b53555 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 21 Jul 2026 13:57:22 +0100 Subject: [PATCH 40/65] doc block fix --- src/services/Api.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services/Api.php b/src/services/Api.php index b7950c4c..fa68ed9b 100644 --- a/src/services/Api.php +++ b/src/services/Api.php @@ -456,8 +456,7 @@ public function createQuery(string $name, array $fields, callable $beforeFields * @param Query|string $query * @param array|null $variables * @return mixed Typically an array with the same structure as the selection, or `null` for nonexistent nodes. - * @throws ShopifyApiException when the response looks unusual (i.e. an `errors` key is present, a `data` key was not returned, or `userErrors` was populated) - * @throws ShopifyApiCommunicationException on a low-level communication failure + * @throws ShopifyApiException when the response looks unusual (i.e. an `errors` key is present, a `data` key was not returned, or `userErrors` was populated), or on a low-level communication failure * @throws \RuntimeException if a session can't be established * @since 6.0.0 */ From 186c2834d7c9726cf7e8793b4283c4e048e31359 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 21 Jul 2026 14:02:25 +0100 Subject: [PATCH 41/65] Throw errors on webhook handle so Shopify knows --- src/controllers/WebhookController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/controllers/WebhookController.php b/src/controllers/WebhookController.php index fe13dfca..2fd8445b 100644 --- a/src/controllers/WebhookController.php +++ b/src/controllers/WebhookController.php @@ -13,6 +13,7 @@ use craft\web\Controller; use yii\web\MethodNotAllowedHttpException; use yii\web\Response as YiiResponse; +use yii\web\ServerErrorHttpException; /** * The WebhookController handles the Shopify webhook request. @@ -30,6 +31,8 @@ class WebhookController extends Controller * Handles the webhooks from Shopify for all topics * * @return YiiResponse + * @throws MethodNotAllowedHttpException if no Shopify API session is available + * @throws ServerErrorHttpException if the webhook could not be processed (e.g. HMAC failure, unknown topic, or a handler error) */ public function actionHandle(): YiiResponse { @@ -47,6 +50,7 @@ public function actionHandle(): YiiResponse ); } catch (\Exception $error) { Craft::error($error->getMessage()); + throw new ServerErrorHttpException('Could not process Shopify webhook. Check the logs for more information.', 0, $error); } $this->response->setStatusCode(200); From 96c4d60da76c73f6083b5d06bfa62e75da9824e2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 21 Jul 2026 14:07:06 +0100 Subject: [PATCH 42/65] Add tests --- tests/unit/helpers/ShopifyHelperTest.php | 273 ++++++++++++++++ tests/unit/webhooks/WebhookRegistryTest.php | 340 ++++++++++++++++++++ 2 files changed, 613 insertions(+) create mode 100644 tests/unit/helpers/ShopifyHelperTest.php create mode 100644 tests/unit/webhooks/WebhookRegistryTest.php diff --git a/tests/unit/helpers/ShopifyHelperTest.php b/tests/unit/helpers/ShopifyHelperTest.php new file mode 100644 index 00000000..a1e7d452 --- /dev/null +++ b/tests/unit/helpers/ShopifyHelperTest.php @@ -0,0 +1,273 @@ + 'my-store.myshopify.com'], 'test_secret_shhh')); + } + + public function testValidateHmacAcceptsValidSignature(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'timestamp' => '1700000000', + 'code' => 'abc123', + // Precomputed: hash_hmac('sha256', 'code=abc123&shop=my-shop.myshopify.com×tamp=1700000000', 'test_secret_shhh') + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacRejectsTamperedParam(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'timestamp' => '1700000000', + 'code' => 'tampered-code', + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacRejectsWrongSecret(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'timestamp' => '1700000000', + 'code' => 'abc123', + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'a-completely-different-secret')); + } + + public function testValidateHmacRejectsGarbageHmacValue(): void + { + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'hmac' => 'not-a-real-signature', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacIsOrderIndependent(): void + { + // Params are sorted internally, so submitting them in a different key order + // should still validate against the same signature. + $params = [ + 'timestamp' => '1700000000', + 'hmac' => 'a681bb12141c571821e74c45d3a83388cbac98af2d498c97c4a782c3af8a31af', + 'code' => 'abc123', + 'shop' => 'my-shop.myshopify.com', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacHandlesArrayValuedParams(): void + { + $params = [ + 'ids' => ['1', '2'], + 'shop' => 'my-shop.myshopify.com', + // Precomputed: hash_hmac('sha256', 'ids=%5B%221%22%2C%222%22%5D&shop=my-shop.myshopify.com', 'test_secret_shhh') + 'hmac' => '89ef6f86296cd4a979fc7c17d7d6f915a89aa2216cf430723a5f9ca131435285', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacAcceptsSignatureOfEmptyParamSet(): void + { + // Edge case: with no params besides `hmac`, the signed query string is empty. + $params = [ + // Precomputed: hash_hmac('sha256', '', 'test_secret_shhh') + 'hmac' => '82783bf294739b92ae7e7be2ff20a30ec2cd80cb8a2536916644268572182039', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + // ------------------------------------------------------------------------- + // validateHmac: encoding / canonicalization edge cases + // ------------------------------------------------------------------------- + + public function testValidateHmacEncodesSpacesAsPlusSign(): void + { + // urlencode() (not rawurlencode()) must be used, so spaces become `+`, matching + // Shopify's own canonical form. Using `%20` here would fail to validate. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'foo bar', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=foo+bar', 'test_secret_shhh') + 'hmac' => '218d30f535682d6c49b473bcaef52bba0748f6d55645cbb470b36d0991521615', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacPercentEncodesAmpersandAndEqualsInValues(): void + { + // A value containing `&` or `=` must be percent-encoded so it can't be mistaken + // for an additional parameter or key/value separator. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'a&b=c', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=a%26b%3Dc', 'test_secret_shhh') + 'hmac' => '8b3daf029726ca3702e44cfc269b32e306ce678d20b806319ac3375e91ff08e5', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacPercentEncodesLiteralPercentSign(): void + { + // A literal `%` in a value must itself be escaped (to `%25`), or it would be + // misread as the start of a percent-encoded sequence. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => '100%', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=100%25', 'test_secret_shhh') + 'hmac' => '40d8975574763e4a0b04b8b0bf355f6346818e2c136a902487188abd37516975', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacPercentEncodesMultibyteUnicodeValue(): void + { + // Multibyte UTF-8 values must be percent-encoded byte-for-byte. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'café', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=caf%C3%A9', 'test_secret_shhh') + 'hmac' => '4e447001ed0870f3526076eb709599631f3b79b0c5fe72b1c3de99eda821bc0b', + ]; + + self::assertTrue(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } + + public function testValidateHmacRejectsMismatchedEncodingOfSpaces(): void + { + // A signature computed against `%20`-encoded spaces (RFC 3986) rather than `+` + // (RFC 1738 / urlencode) must NOT validate. + $params = [ + 'shop' => 'my-shop.myshopify.com', + 'state' => 'foo bar', + // Precomputed: hash_hmac('sha256', 'shop=my-shop.myshopify.com&state=foo%20bar', 'test_secret_shhh') + 'hmac' => 'e466c63f23328c630405d39eb7b0840046472e586d3aea536324a70d878430ed', + ]; + + self::assertFalse(ShopifyHelper::validateHmac($params, 'test_secret_shhh')); + } +} diff --git a/tests/unit/webhooks/WebhookRegistryTest.php b/tests/unit/webhooks/WebhookRegistryTest.php new file mode 100644 index 00000000..f16535f9 --- /dev/null +++ b/tests/unit/webhooks/WebhookRegistryTest.php @@ -0,0 +1,340 @@ +_resetRegistry(); + } + + protected function _after(): void + { + $this->_resetRegistry(); + } + + // ------------------------------------------------------------------------- + // addHandler / getHandler + // ------------------------------------------------------------------------- + + public function testGetHandlerReturnsNullWhenNoneRegistered(): void + { + self::assertNull(WebhookRegistry::getHandler(WebhookTopics::ProductsCreate)); + } + + public function testAddHandlerIsRetrievableByGetHandler(): void + { + $handler = new class { + public function handle(): void + { + } + }; + + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $handler); + + self::assertSame($handler, WebhookRegistry::getHandler(WebhookTopics::ProductsCreate)); + } + + public function testAddHandlerOverwritesExistingHandlerForSameTopic(): void + { + $first = new class { + public function handle(): void + { + } + }; + $second = new class { + public function handle(): void + { + } + }; + + WebhookRegistry::addHandler(WebhookTopics::ShopUpdate, $first); + WebhookRegistry::addHandler(WebhookTopics::ShopUpdate, $second); + + self::assertSame($second, WebhookRegistry::getHandler(WebhookTopics::ShopUpdate)); + } + + public function testAddHandlerDoesNotAffectOtherTopics(): void + { + $handler = new class { + public function handle(): void + { + } + }; + + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $handler); + + self::assertNull(WebhookRegistry::getHandler(WebhookTopics::ProductsDelete)); + } + + // ------------------------------------------------------------------------- + // process — happy path + // ------------------------------------------------------------------------- + + public function testProcessDispatchesToRegisteredHandlerWithCorrectArguments(): void + { + $received = null; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function(...$args) use (&$received) { + $received = $args; + })); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + + self::assertNotNull($received); + [$topic, $shop, $body] = $received; + self::assertSame(WebhookTopics::ProductsCreate, $topic); + self::assertSame(self::SHOP, $shop); + self::assertEquals(['id' => 123, 'title' => 'Test Product'], $body); + } + + public function testProcessAcceptsLowercaseHeaderKeys(): void + { + $called = false; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function() use (&$called) { + $called = true; + })); + + WebhookRegistry::process( + [ + 'x-shopify-topic' => 'products/create', + 'x-shopify-shop-domain' => self::SHOP, + 'x-shopify-hmac-sha256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + + self::assertTrue($called); + } + + public function testProcessAcceptsArrayHeaderValues(): void + { + $called = false; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function() use (&$called) { + $called = true; + })); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => ['products/create'], + 'X-Shopify-Shop-Domain' => [self::SHOP], + 'X-Shopify-Hmac-SHA256' => [self::VALID_BODY_HMAC], + ], + self::VALID_BODY, + self::SECRET, + ); + + self::assertTrue($called); + } + + public function testProcessPassesEmptyArrayForNonJsonBody(): void + { + $received = null; + WebhookRegistry::addHandler(WebhookTopics::ProductsCreate, $this->_spyHandler(function(...$args) use (&$received) { + $received = $args; + })); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::INVALID_JSON_BODY_HMAC, + ], + self::INVALID_JSON_BODY, + self::SECRET, + ); + + self::assertEquals([], $received[2]); + } + + // ------------------------------------------------------------------------- + // process — failure paths + // ------------------------------------------------------------------------- + + public function testProcessThrowsWhenTopicHeaderMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing required Shopify webhook headers.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsWhenShopHeaderMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing required Shopify webhook headers.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsWhenHmacHeaderMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing required Shopify webhook headers.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsOnInvalidHmac(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Webhook HMAC validation failed.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => 'not-a-real-signature', + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsOnInvalidHmacWhenBodyIsTamperedAfterSigning(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Webhook HMAC validation failed.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + '{"id":123,"title":"Tampered Product"}', + self::SECRET, + ); + } + + public function testProcessThrowsOnInvalidHmacWithWrongSecret(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Webhook HMAC validation failed.'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + 'a-completely-different-secret', + ); + } + + public function testProcessThrowsOnUnknownTopic(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unknown webhook topic: orders/create'); + + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'orders/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + public function testProcessThrowsWhenNoHandlerRegisteredForTopic(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No handler registered for webhook topic: products/create'); + + // Deliberately not calling WebhookRegistry::addHandler() for this topic. + WebhookRegistry::process( + [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Shop-Domain' => self::SHOP, + 'X-Shopify-Hmac-SHA256' => self::VALID_BODY_HMAC, + ], + self::VALID_BODY, + self::SECRET, + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function _spyHandler(callable $onHandle): object + { + return new class($onHandle) { + public function __construct(private $onHandle) + { + } + + public function handle(...$args): void + { + ($this->onHandle)(...$args); + } + }; + } + + private function _resetRegistry(): void + { + $property = new \ReflectionProperty(WebhookRegistry::class, 'registry'); + $property->setAccessible(true); + $property->setValue(null, []); + } +} From 9f28dedb1516e9f34498c76864a63c6cb7f57075 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 22 Jul 2026 08:25:42 +0100 Subject: [PATCH 43/65] Tidy changelog --- CHANGELOG-WIP.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 2d7a61eb..b7fa8f99 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1,6 +1,8 @@ # WIP Release Notes for Shopify 8.0 > [!IMPORTANT] +> Ensure the Craft queue is fully drained before upgrading. Any pending sync jobs will be unable to update their status after the migration runs. +> > If you change the **Additional Features** or **Custom Scopes** settings after the app is already authorized, you must update the scopes in your Shopify app configuration and then re-authorize the app. ### Store Management @@ -13,7 +15,17 @@ ### Extensibility +- Added `craft\shopify\auth\OAuthFlow`. +- Added `craft\shopify\clients\GraphqlClient`. - Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. +- Added `craft\shopify\enums\ApiVersion`. +- Added `craft\shopify\enums\WebhookTopics`. +- Added `craft\shopify\exceptions\InvalidOAuthException`. +- Added `craft\shopify\exceptions\ShopifyApiCommunicationException`. +- Added `craft\shopify\exceptions\ShopifyApiException`. +- Added `craft\shopify\helpers\Metafield`. +- Added `craft\shopify\helpers\ShopifyHelper`. +- Added `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyGid`. - Added `craft\shopify\models\BulkOperation::$shopifyGid`. - Added `craft\shopify\models\Settings::REQUIRED_SCOPES`. - Added `craft\shopify\models\Settings::getAdditionalFeatures()`. @@ -23,27 +35,19 @@ - Added `craft\shopify\models\Settings::setAdditionalFeatures()`. - Added `craft\shopify\models\Settings::setCustomScopes()`. - Added `craft\shopify\models\Variant::$shopifyGid`. -- Added `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyGid`. +- Added `craft\shopify\services\Api::connect()`. +- Added `craft\shopify\services\Api::getShopLocalesGql()`. +- Added `craft\shopify\services\Api::getWebhookTopics()`. - Added `craft\shopify\services\BulkOperations::getBulkOperationByShopifyGid()`. - Added `craft\shopify\services\Products::deleteProductByShopifyGid()`. - Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. -- Added `craft\shopify\services\Api::getShopLocalesGql()`. -- Added `craft\shopify\services\Api::connect()`. -- Added `craft\shopify\services\Api::getWebhookTopics()`. -- Added `craft\shopify\auth\OAuthFlow`. -- Added `craft\shopify\clients\GraphqlClient`. -- Added `craft\shopify\enums\ApiVersion`. -- Added `craft\shopify\enums\WebhookTopics`. -- Added `craft\shopify\exceptions\InvalidOAuthException`. -- Added `craft\shopify\exceptions\ShopifyApiCommunicationException`. -- Added `craft\shopify\exceptions\ShopifyApiException`. -- Added `craft\shopify\helpers\ShopifyHelper`. - Added `craft\shopify\webhooks\WebhookRegistry`. - `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. - `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. - `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. +- `craft\shopify\elements\Product::setMetafields()` and `craft\shopify\models\Variant::setMetafields()` now require a list-shaped array of `{key, value}` objects (or a JSON-encoded string of the same), and throw `\InvalidArgumentException` for anything else. Previously, an associative `key => value` map was also accepted without validation. - API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. - Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`. - Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`. @@ -62,4 +66,4 @@ - Fixed a bug where validation errors for the "Context Pricing Countries" setting weren't displaying correctly. - Fixed a bug where `inventory_levels/update` webhooks weren't triggering a product sync. - Removed the `shopify/shopify-api` Composer dependency. -- Shopify for Craft now requires Craft CMS 5.10.7 or later. +- Shopify for Craft now requires Craft CMS 5.10.7 or later. Craft 4 is no longer supported. From 9ccda9ab0feac87d94655a81a4f5891b2bd069cc Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 22 Jul 2026 13:16:20 +0100 Subject: [PATCH 44/65] README updates --- README.md | 20 ++-- tests/_craft/templates/shopify-gid-query.twig | 3 + .../templates/shopify-options-search.twig | 3 + tests/fixtures/ShopifyDataFixture.php | 2 +- tests/unit/twig/ProductQueryTemplatesTest.php | 93 +++++++++++++++++++ 5 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 tests/_craft/templates/shopify-gid-query.twig create mode 100644 tests/_craft/templates/shopify-options-search.twig create mode 100644 tests/unit/twig/ProductQueryTemplatesTest.php diff --git a/README.md b/README.md index bd57cffa..e1f40191 100644 --- a/README.md +++ b/README.md @@ -486,7 +486,7 @@ Filter by [Shopify GIDs](https://shopify.dev/docs/api/admin-graphql/2026-01/scal ```twig {# Watch out—these aren't the same as element IDs! #} {% set singleProduct = craft.shopifyProducts - .shopifyId('gid://shopify/Product/123456789') + .shopifyGid('gid://shopify/Product/123456789') .one() %} ``` @@ -546,9 +546,9 @@ Tags are stored as a JSON array, which may complicate direct comparisons. You ma Options are stored as a JSON array, which may complicate direct comparisons. You may see better results using [the `.search()` param](https://craftcms.com/docs/5.x/system/searching.html#development). ```twig -{# Find products whose options include a `size` key: #} +{# Find products with an option value like "Large": #} {% set clogs = craft.shopifyProducts - .tags('*"size"*') + .search('*Large*') .all() %} ``` @@ -842,7 +842,7 @@ If you want to let customers pick from _options_ instead of directly select from id: 'variant', data: { variants: product.variants | map(v => { - gid: v.shopifyId, + gid: v.shopifyGid, selectedOptions: v.data.selectedOptions, }), }, @@ -1006,7 +1006,7 @@ See the [usage examples](https://github.com/Shopify/shopify-app-js/tree/main/pac ```twig {% for variant in product.variants %} - + {% endfor %} ``` @@ -1240,8 +1240,8 @@ The following settings can also be set via a `shopify.php` file in your `config/ | Setting | Type | Default | Description | |------------------------------|------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `apiKey` | `string` | — | Shopify API key. | -| `apiSecretKey` | `string` | — | Shopify API secret key. | +| `clientId` | `string` | — | Shopify API client ID. | +| `clientSecret` | `string` | — | Shopify API client secret key. | | `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. | | `accessToken` | `string` | — | Shopify API access token. | | `additionalFeatures` | `string[]` | `[]` | Array of additional feature handles to enable (e.g. `['productTranslations']`). Enabling features may add required API scopes; see [Additional Features](#additional-features). | @@ -1252,7 +1252,7 @@ The following settings can also be set via a `shopify.php` file in your `config/ | `template` | `string` | — | Product element template path. | > [!NOTE] -> Setting `apiKey`, `apiSecretKey`, `apiVersion`, `accessToken`, or `hostName` via `shopify.php` will override Project Config values set via the control panel during [app setup](#connect-to-shopify). +> Setting `clientId`, `clientSecret`, `apiVersion`, `accessToken`, or `hostName` via `shopify.php` will override Project Config values set via the control panel during [app setup](#connect-to-shopify). > You can still reference environment values from the config file with `craft\helpers\App::env()`. ### Additional Features @@ -1526,7 +1526,7 @@ query getProductPrice($id: ID!) { {# Execute the query #} {% set response = craft.shopify.api.query(priceQuery, { - id: product.shopifyId + id: product.shopifyGid }) %} {# Access the pricing data #} @@ -1546,7 +1546,7 @@ Key elements of this approach: - `contextualPricing(context: {country: XX})` returns market-specific prices (use the country code directly, e.g., `GB`, `US`, `DE`) - Use an alias like `gbPricing:` to name the result for easy access in Twig -- Pass the product's `shopifyId` (already a GID) directly to the query +- Pass the product's `shopifyGid` (already a GID) directly to the query - The `query()` method returns the first result directly, so access `response.variants` (not `response.data.product.variants`) - Falls back to the default `variant.price` if contextual pricing is not available diff --git a/tests/_craft/templates/shopify-gid-query.twig b/tests/_craft/templates/shopify-gid-query.twig new file mode 100644 index 00000000..6bc33d84 --- /dev/null +++ b/tests/_craft/templates/shopify-gid-query.twig @@ -0,0 +1,3 @@ +{# Mirrors the README's `#### shopifyGid` query-param example. #} +{% set product = craft.shopifyProducts.shopifyGid(gid).one() %} +{{- product ? product.shopifyGid : '' -}} diff --git a/tests/_craft/templates/shopify-options-search.twig b/tests/_craft/templates/shopify-options-search.twig new file mode 100644 index 00000000..626faeed --- /dev/null +++ b/tests/_craft/templates/shopify-options-search.twig @@ -0,0 +1,3 @@ +{# Mirrors the README's `#### options` query-param example. #} +{% set products = craft.shopifyProducts.search('*yellow*').all() %} +{{- products|length -}}:{{- products[0] is defined ? products[0].shopifyGid : '' -}} diff --git a/tests/fixtures/ShopifyDataFixture.php b/tests/fixtures/ShopifyDataFixture.php index db8892cb..e6a8a813 100644 --- a/tests/fixtures/ShopifyDataFixture.php +++ b/tests/fixtures/ShopifyDataFixture.php @@ -31,7 +31,7 @@ public function load(): void \Yii::$app->db->createCommand()->insert(Table::DATA, [ 'shopifyGid' => $row['shopifyGid'], 'type' => $row['type'], - 'data' => is_array($row['data']) ? json_encode($row['data']) : $row['data'], + 'data' => $row['data'], 'parentId' => $row['parentId'], 'uid' => $uid, 'dateCreated' => $row['dateCreated'], diff --git a/tests/unit/twig/ProductQueryTemplatesTest.php b/tests/unit/twig/ProductQueryTemplatesTest.php new file mode 100644 index 00000000..adfeaa75 --- /dev/null +++ b/tests/unit/twig/ProductQueryTemplatesTest.php @@ -0,0 +1,93 @@ + ['class' => ShopifyDataFixture::class], + ]; + } + + protected function _before(): void + { + parent::_before(); + + Plugin::getInstance()->getProducts()->createOrUpdateProduct([ + 'id' => self::PRODUCT_GID, + 'title' => 'TIMBERLAND | MENS 6 INCH PREMIUM BOOT', + 'descriptionHtml' => null, + 'createdAt' => '2026-01-01T00:00:00Z', + 'handle' => 'timberland-mens-6-inch-premium-boot', + 'options' => [], + 'productType' => null, + 'publishedAt' => '2026-01-01T00:00:00Z', + 'status' => 'ACTIVE', + 'tags' => ['egnition-sample-data', 'men', 'timberland', 'winter'], + 'templateSuffix' => null, + 'updatedAt' => '2026-01-01T00:00:00Z', + 'vendor' => 'TIMBERLAND', + ]); + } + + public function testShopifyGidQueryParamResolvesProductFromReadmeExample(): void + { + // Mirrors the README's `#### shopifyGid` example: + // `.shopifyGid('gid://shopify/Product/123456789')` + $output = Craft::$app->getView()->renderTemplate('shopify-gid-query', [ + 'gid' => self::PRODUCT_GID, + ], View::TEMPLATE_MODE_SITE); + + self::assertSame(self::PRODUCT_GID, trim($output)); + } + + public function testOptionsSearchParamResolvesProductFromReadmeExample(): void + { + // `options` is sourced entirely from the `shopify_data` join (see class + // docblock) — createOrUpdateProduct() can't override it, so this relies + // on the fixture's own "Color: yellow" option, set up in _before(). + + // Search indexing only runs synchronously on console requests in real + // usage (e.g. queue-driven syncs); our test module simulates a web + // request, so it's queued instead. Index explicitly to match that. + $product = Product::find()->shopifyGid(self::PRODUCT_GID)->status(null)->one(); + Craft::$app->getSearch()->indexElementAttributes($product); + + // Mirrors the README's `#### options` example: `.search('*yellow*')` + $output = Craft::$app->getView()->renderTemplate('shopify-options-search', [], View::TEMPLATE_MODE_SITE); + + self::assertSame('1:' . self::PRODUCT_GID, trim($output)); + } +} From 4e0cc6e3d1f9b1083db9c2e68f33cca68c3de788 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 22 Jul 2026 13:37:33 +0100 Subject: [PATCH 45/65] udpate readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index e1f40191..6027ad79 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,21 @@ Shopify for Craft 8.0 requires **Craft CMS 5.10.7 or later** and drops support f `craft\shopify\models\Variant::$shopifyId` now holds only the **numeric** Shopify ID (e.g. `”123456789”`). The full GID (e.g. `”gid://shopify/ProductVariant/123456789”`) is available via the new `$shopifyGid` property. Update any templates or custom code that compared or used `$variant->shopifyId` as a GID string. +> [!WARNING] +> This also changes the plugin’s GraphQL API: querying a variant’s `shopifyId` field previously returned the full GID, and now returns the numeric ID only. Use the `shopifyGid` field if you need the full GID. If you have external clients or headless front-ends querying this plugin’s GraphQL API, audit them for this change. + +The following methods are deprecated in favor of GID-based equivalents. Update any direct calls: + +- `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()` → `getBulkOperationByShopifyGid()` +- `craft\shopify\services\Products::deleteProductByShopifyId()` → `deleteProductByShopifyGid()` +- `craft\shopify\services\Products::deleteShopifyDataByShopifyId()` → `deleteShopifyDataByShopifyGid()` +- `craft\shopify\services\Products::syncProductByShopifyId()` → `syncProductByShopifyGid()` + > [!WARNING] > The `shopify/shopify-api` package is no longer a dependency of this plugin. If any custom code references its classes directly—like `Shopify\Clients\Graphql`, `Shopify\Exception\ShopifyException`, `Shopify\Webhooks\Registry`, `Shopify\Auth\OAuth`, or `Shopify\Context`—update it to use the plugin’s own equivalents (`craft\shopify\clients\GraphqlClient`, `craft\shopify\exceptions\ShopifyApiException`, `craft\shopify\webhooks\WebhookRegistry`, `craft\shopify\auth\OAuthFlow`) instead. +If you plan to enable any of the new [Additional Features](#additional-features) or the `customScopes` setting as part of this upgrade, see the scope re-authorization requirements described there—enabling them after the app is already authorized requires updating your Shopify app’s scopes and re-authorizing. + > [!TIP] > The [changelog](https://github.com/craftcms/shopify/blob/8.x/CHANGELOG.md) contains a full list of added, changed, and deprecated classes and methods. From 4c68df8cf4bf7cb05b9351e908ead1f16f838aa7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 22 Jul 2026 13:38:13 +0100 Subject: [PATCH 46/65] Remove previously deprecated items --- CHANGELOG-WIP.md | 5 +++ src/console/controllers/SyncController.php | 26 ------------ src/models/Settings.php | 48 ---------------------- 3 files changed, 5 insertions(+), 74 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index b7fa8f99..7e37a077 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -55,6 +55,11 @@ - Deprecated `craft\shopify\services\Products::deleteProductByShopifyId()`. Use `deleteProductByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::deleteShopifyDataByShopifyId()`. Use `deleteShopifyDataByShopifyGid()` instead. - Deprecated `craft\shopify\services\Products::syncProductByShopifyId()`. Use `syncProductByShopifyGid()` instead. +- Removed `craft\shopify\console\controllers\SyncController::$throttle`. +- Removed `craft\shopify\models\Settings::getApiKey()`. Use `getClientId()` instead. +- Removed `craft\shopify\models\Settings::getApiSecretKey()`. Use `getClientSecret()` instead. +- Removed `craft\shopify\models\Settings::setApiKey()`. Use `setClientId()` instead. +- Removed `craft\shopify\models\Settings::setApiSecretKey()`. Use `setClientSecret()` instead. - Removed `craft\shopify\services\Api::getSession()`. Use `connect()` instead. - Removed `craft\shopify\services\Api::initializeContext()`. - Removed `craft\shopify\services\Api::WEBHOOK_TOPICS`. Use `getWebhookTopics()` instead. diff --git a/src/console/controllers/SyncController.php b/src/console/controllers/SyncController.php index 277d349d..19b9e2e4 100644 --- a/src/console/controllers/SyncController.php +++ b/src/console/controllers/SyncController.php @@ -23,32 +23,6 @@ class SyncController extends Controller /** @var string $defaultAction */ public $defaultAction = 'all'; - /** - * @var bool Whether to slow down API requests to avoid rate limiting. - * @since 5.2.0 - * @deprecated 7.0.0 - */ - public bool $throttle = false; - - /** - * @inheritdoc - */ - public function options($actionID): array - { - $options = parent::options($actionID); - $options[] = 'throttle'; - return $options; - } - - public function beforeAction($action): bool - { - if ($this->throttle) { - $this->stdout('The --throttle option has been deprecated and has no effect, as we fetch product data in bulk (and therefore are not subject to API rate limiting).' . PHP_EOL, Console::FG_YELLOW); - } - - return parent::beforeAction($action); - } - /** * Sync all Shopify data. */ diff --git a/src/models/Settings.php b/src/models/Settings.php index 830d196d..b0e6b939 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -140,30 +140,6 @@ public function getApiVersion(bool $parse = true): string return ($parse ? App::parseEnv($this->_apiVersion) : $this->_apiVersion) ?? ''; } - /** - * @param string $apiKey - * @return void - * @since 6.0.0 - * @deprecated in 7.0.0. Use [[setClientId()]] instead. - */ - public function setApiKey(string $apiKey): void - { - Craft::$app->getDeprecator()->log(__METHOD__, '`setApiKey()` method has been deprecated. Use `setClientId()` instead.'); - return; - } - - /** - * @param bool $parse - * @return string - * @since 6.0.0 - * @deprecated in 7.0.0. Use [[getClientId()]] instead. - */ - public function getApiKey(bool $parse = true): string - { - Craft::$app->getDeprecator()->log(__METHOD__, '`getApiKey()` method has been deprecated. Use `getClientId()` instead.'); - return $this->getClientId($parse); - } - /** * @param string $clientId * @return void @@ -184,30 +160,6 @@ public function getClientId(bool $parse = true): string return ($parse ? App::parseEnv($this->_clientId) : $this->_clientId) ?? ''; } - /** - * @param string $apiSecretKey - * @return void - * @since 6.0.0 - * @deprecated in 7.0.0. Use [[setClientSecret()]] instead. - */ - public function setApiSecretKey(string $apiSecretKey): void - { - Craft::$app->getDeprecator()->log(__METHOD__, '`setApiSecretKey()` method has been deprecated. Use `setClientSecret()` instead.'); - return; - } - - /** - * @param bool $parse - * @return string - * @since 6.0.0 - * @deprecated in 7.0.0. Use [[getClientSecret()]] instead. - */ - public function getApiSecretKey(bool $parse = true): string - { - Craft::$app->getDeprecator()->log(__METHOD__, '`getApiSecretKey()` method has been deprecated. Use `getClientSecret()` instead.'); - return $this->getClientSecret($parse); - } - /** * @param string $clientSecret * @return void From 8792f7397a9f597609010e2e8bf0581d3cc05d00 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:12:39 -0700 Subject: [PATCH 47/65] Header levels --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6027ad79..c668b20e 100644 --- a/README.md +++ b/README.md @@ -250,23 +250,23 @@ After the upgrade, you **must** [delete and re-create](#set-up-webhooks) webhook Your “legacy custom app” can be left as-is or deleted, once all your environments have been migrated to the Dev Dashboard connection. While this plugin has no need for those credentials, confirm with the store owner that no other external services depend on them! -### Credentials +#### Credentials At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard). You should be able to [create a new app](#create-an-app), and [install it](#install-in-a-store) using the new OAuth mechanism, without disruption to product synchronization. -### Publishing and Status +#### Publishing and Status Shopify has eliminated [sales channels for custom apps](https://shopify.dev/docs/apps/build/sales-channels/start-building), and therefore the [`publishedOnCurrentPublication` field](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product#field-Product.fields.publishedOnCurrentChannel) is no longer available in Product queries. This means that there is no official way to “publish” products to the Craft integration, but we cover some alternatives in the [sales channel emulation](#emulate-sales-channels) section. -### Product Field Layouts +#### Product Field Layouts The product element editor has received a major overhaul. You can now choose exactly where Shopify data is placed, within the [field layout](#custom-fields). -### Front-End SDKs +#### Front-End SDKs Shopify has retired many of its pre-built client-side frameworks, in favor of directly communicating with the generic [Storefront GraphQL API](#storefront-api-client). You will need to revise how you query and mutate data, if your front-end currently depends on the JS Buy SDK or Buy Button JS. From 09952654d1459dba56e555ce42b5c500a54ca63c Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:13:22 -0700 Subject: [PATCH 48/65] Generalize upgrade alert --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c668b20e..e3231eec 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com) products into [Craft CMS](https://craftcms.com/). > [!IMPORTANT] -> Version 7.x of Shopify for Craft uses a new app-based authorization system. -> You must follow the [upgrade instructions](#upgrading) to get new credentials. +> Please review the [upgrade instructions](#upgrading) for some important changes. ## Topics From 493a0f5e26228bdab276572cae46f6a948c60345 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:14:09 -0700 Subject: [PATCH 49/65] Upgrade intro --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e3231eec..eccb3fb7 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,8 @@ Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://sho ## Upgrading +> While it is technically possible to upgrade directly from 6.x to the latest 8.x version, we strongly recommend reviewing the [6.x upgrade guide](#from-6x), as an intermediate step. + ### From 7.x > [!WARNING] From 226cd4891a7913ac152ec55b33b86487500eab95 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:17:01 -0700 Subject: [PATCH 50/65] 8.x upgrade cleanup --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eccb3fb7..9f2cbe23 100644 --- a/README.md +++ b/README.md @@ -213,14 +213,23 @@ Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://sho ### From 7.x > [!WARNING] -> Ensure the Craft queue is fully drained before upgrading. Any pending sync jobs will be unable to update their status after the migration runs. +> Ensure the Craft queue is empty, before upgrading. +> Any pending [sync](#synchronization) jobs will be unable to update their status after the migration runs. +> In-progress bulk-synchronization operations (in Shopify) should be unaffected, unless you opt in to [additional features](#additional-features) during the upgrade. -Shopify for Craft 8.0 requires **Craft CMS 5.10.7 or later** and drops support for Craft 4. +Shopify 8.0 requires **Craft CMS 5.10.7 or later**, and drops support for Craft 4.x. -`craft\shopify\models\Variant::$shopifyId` now holds only the **numeric** Shopify ID (e.g. `”123456789”`). The full GID (e.g. `”gid://shopify/ProductVariant/123456789”`) is available via the new `$shopifyGid` property. Update any templates or custom code that compared or used `$variant->shopifyId` as a GID string. +The most significant change for most developers will be our handling of Shopify IDs and GIDs. +`craft\shopify\models\Variant::$shopifyId` now holds only the **numeric** Shopify ID (e.g. `”123456789”`). +The full GID (e.g. `”gid://shopify/ProductVariant/123456789”`) is available via the new `$shopifyGid` property. +**Update any templates or custom code that compared or used `$variant->shopifyId` as a GID string.** + +Examples in this document reflect this change; you should no longer need to to manipulate the GID string for add-to-cart forms or other situations that required the numeric ID. > [!WARNING] -> This also changes the plugin’s GraphQL API: querying a variant’s `shopifyId` field previously returned the full GID, and now returns the numeric ID only. Use the `shopifyGid` field if you need the full GID. If you have external clients or headless front-ends querying this plugin’s GraphQL API, audit them for this change. +> This also changes the plugin’s GraphQL API: querying a variant’s `shopifyId` field previously returned the full GID, and now returns the numeric ID only. +> Use the `shopifyGid` field if you need the full GID. +> If you have external clients or headless front-ends querying this plugin’s GraphQL API, audit them for this change. The following methods are deprecated in favor of GID-based equivalents. Update any direct calls: From 87f703587700c36cadf194c6c35ea0fd23f3a86e Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:17:20 -0700 Subject: [PATCH 51/65] 7.x upgrade notes --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f2cbe23..e9beaec8 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,10 @@ If you plan to enable any of the new [Additional Features](#additional-features) ### From 6.x -This version (7.x) is primarily concerned with Shopify API compatibility, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). +> These instructions were originally published with the release of 7.x, but we have adapted them here for convenience. +> You only need to follow these instructions if you are upgrading from 6.x directly to 8.x. + +Version 7.0 was primarily concerned with Shopify API compatibility, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable. From 856464a37c29eaf43148ce905c3b2c6991cc31a3 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:17:32 -0700 Subject: [PATCH 52/65] Link bounds, newline --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e9beaec8..d356a31c 100644 --- a/README.md +++ b/README.md @@ -259,9 +259,11 @@ Due to significant shifts in Shopify’s developer ecosystem, many of the [front > We strongly recommend reviewing this same section on the [6.x](https://github.com/craftcms/shopify/blob/6.x/README.md#upgrading) branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. > The [changelog](https://github.com/craftcms/shopify/blob/7.x/CHANGELOG.md) contains specific information about the classes and methods that have been added, removed, or deprecated. -After the upgrade, you **must** [delete and re-create](#set-up-webhooks) webhooks for each environment. Webhooks are registered and delivered with a specific version, and a mismatch will result in errors. +After the upgrade, you **must** [delete and re-create webhooks](#set-up-webhooks) for each environment. +Webhooks are registered and delivered with a specific version, and a mismatch will result in errors. -Your “legacy custom app” can be left as-is or deleted, once all your environments have been migrated to the Dev Dashboard connection. While this plugin has no need for those credentials, confirm with the store owner that no other external services depend on them! +Your “legacy custom app” can be left as-is or deleted, once all your environments have been migrated to the Dev Dashboard connection. +While this plugin has no need for those credentials, confirm with the store owner that no other external services depend on them! #### Credentials From 6c557e55241dfc5bc6a926513d4a5414f9281bac Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:17:43 -0700 Subject: [PATCH 53/65] Link to elements docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d356a31c..9b81978c 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ Going forward, your products are automatically kept in sync via [webhooks](#set- ### Native Attributes -In addition to the standard element attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product): +In addition to the standard [element](https://craftcms.com/docs/5.x/system/elements.html) attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product): | Attribute | Description | Type | |------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------| From 019cccb7620aed29b6b1f33941dd49923d83fb9b Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:18:33 -0700 Subject: [PATCH 54/65] Product::getVariants() clarifications --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b81978c..2cb803b7 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ The product element has a few methods you might find useful in your [templates]( #### `Product::getVariants()` -Returns an array of [variants](#variants-and-pricing) belonging to the product. +Returns a collection of [variants](#variants-and-pricing) belonging to the product. Variants are _not_ elements (just regular models), but you can use the same dot notation to access their properties: ```twig @@ -354,11 +354,15 @@ Variants are _not_ elements (just regular models), but you can use the same dot ``` +> [!NOTICE] +> Like products, variants’ `id`s are Craft-specific identifiers. +> Use `shopifyGid` or `shopifyId` for the canonical Shopify values. + You can [eager-load](#eager-loading) variants alongside products using the [product query](#querying-products)’s `.withVariants()` method. #### `Product::getDefaultVariant()` From eafff0e788662148bba2f2e690f03a5031a70aae Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:19:02 -0700 Subject: [PATCH 55/65] Additional `shopifyGid` warning --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2cb803b7..e5d9901a 100644 --- a/README.md +++ b/README.md @@ -515,7 +515,7 @@ Filter by legacy numeric Shopify product IDs. Filter by [Shopify GIDs](https://shopify.dev/docs/api/admin-graphql/2026-01/scalars/ID). ```twig -{# Watch out—these aren't the same as element IDs! #} +{# Watch out! These aren’t the same as element IDs or Shopify IDs. #} {% set singleProduct = craft.shopifyProducts .shopifyGid('gid://shopify/Product/123456789') .one() %} From 68502882365a46d5ddb5408804d00dea7ee049e4 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:20:49 -0700 Subject: [PATCH 56/65] =?UTF-8?q?Remove=20Buy=20Button=20and=20JS=20Buy=20?= =?UTF-8?q?SDK=20sections.=20These=20are=20basically=20unsupported=20by=20?= =?UTF-8?q?Shopify,=20and/or=20require=20a=20great=20deal=20of=20additiona?= =?UTF-8?q?l=20setup.=20The=20**Storefront=20API=20Client**=20section=20in?= =?UTF-8?q?cludes=20a=20basic=20(but=20complete)=20example=20of=20cart=20m?= =?UTF-8?q?anagement=E2=80=A6=20and=20this=20seems=20to=20be=20what=20they?= =?UTF-8?q?=20are=20recommending,=20now=E2=80=94despite=20it=20requiring?= =?UTF-8?q?=20the=20separate=20=E2=80=9CHeadless=E2=80=9D=20app.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index e5d9901a..4ed1afe8 100644 --- a/README.md +++ b/README.md @@ -1000,18 +1000,6 @@ Your customers can add products to their cart directly from your Craft site by ` ``` -### JS Buy SDK - -The JS Buy SDK is no longer maintained, and is not compatible with the new APIs or authorization scheme. - -### Buy Button JS - -The above example can be simplified with the [Buy Button JS](https://shopify.dev/custom-storefronts/tools/buy-button), which provides some ready-made UI components, like a fully-featured cart. The principles are the same: - -1. Make products available via the appropriate sales channels in Shopify; -2. Output synchronized product data in your front-end; -3. Initialize, attach, or trigger SDK functionality in response to events, using Shopify-specific identifiers from step #2; - ### Storefront API Client > [!WARNING] From 1cf5909282fccf835eaf9b634fb3ec31452bc0d4 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:21:07 -0700 Subject: [PATCH 57/65] Notes at top of Changelog --- CHANGELOG-WIP.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 7e37a077..cad34dd3 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1,10 +1,15 @@ # WIP Release Notes for Shopify 8.0 > [!IMPORTANT] -> Ensure the Craft queue is fully drained before upgrading. Any pending sync jobs will be unable to update their status after the migration runs. +> Ensure the Craft queue is empty before upgrading. Any pending sync jobs will be unable to update their status after the migration runs. > > If you change the **Additional Features** or **Custom Scopes** settings after the app is already authorized, you must update the scopes in your Shopify app configuration and then re-authorize the app. +This is primarily a maintenance release, focusing on Shopify API compatibility, authorization, and overall consistency. + +Developers should review their templates and extensions for potentially breaking changes to products’ and variants’ `shopifyId` property. +See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgrading) section in the readme for more information. + ### Store Management - Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215)) From 0673b2865205b4d5a9f160a59a53b3f0ef1cca03 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:23:32 -0700 Subject: [PATCH 58/65] Hoist actionable changelog entries (extensibility) --- CHANGELOG-WIP.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index cad34dd3..23669360 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -20,6 +20,13 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra ### Extensibility +- `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. +- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. +- `craft\shopify\elements\Product::setMetafields()` and `craft\shopify\models\Variant::setMetafields()` now require a list-shaped array of `{key, value}` objects (or a JSON-encoded string of the same), and throw `\InvalidArgumentException` for anything else. Previously, an associative `key => value` map was also accepted without validation. +- API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. +- - Added `craft\shopify\auth\OAuthFlow`. - Added `craft\shopify\clients\GraphqlClient`. - Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. @@ -48,12 +55,6 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra - Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. - Added `craft\shopify\webhooks\WebhookRegistry`. -- `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. -- `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. -- `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. -- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. -- `craft\shopify\elements\Product::setMetafields()` and `craft\shopify\models\Variant::setMetafields()` now require a list-shaped array of `{key, value}` objects (or a JSON-encoded string of the same), and throw `\InvalidArgumentException` for anything else. Previously, an associative `key => value` map was also accepted without validation. -- API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. - Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`. - Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`. - Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead. From d68c995da040b497fc5e620289655aeeb5f7dfbc Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:24:05 -0700 Subject: [PATCH 59/65] =?UTF-8?q?Additional=E2=80=A6=20removal=3F=20:sweat?= =?UTF-8?q?=5Fsmile:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG-WIP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 23669360..8a1b6143 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -69,6 +69,7 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra - Removed `craft\shopify\services\Api::getSession()`. Use `connect()` instead. - Removed `craft\shopify\services\Api::initializeContext()`. - Removed `craft\shopify\services\Api::WEBHOOK_TOPICS`. Use `getWebhookTopics()` instead. +- Removed `craft\shopify\events\DefineContextConfigEvent` and `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG`. ### System From 867a93bcd7066c3303bc211b1039c76c118325a8 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:24:13 -0700 Subject: [PATCH 60/65] Remove blank item --- CHANGELOG-WIP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 8a1b6143..4c346ed7 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -26,7 +26,6 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra - `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. - `craft\shopify\elements\Product::setMetafields()` and `craft\shopify\models\Variant::setMetafields()` now require a list-shaped array of `{key, value}` objects (or a JSON-encoded string of the same), and throw `\InvalidArgumentException` for anything else. Previously, an associative `key => value` map was also accepted without validation. - API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. -- - Added `craft\shopify\auth\OAuthFlow`. - Added `craft\shopify\clients\GraphqlClient`. - Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. From d5e1ae0f40c629bf3a9b73bd3b01e815fba4333c Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 14:24:41 -0700 Subject: [PATCH 61/65] Cleanup: already imported --- src/collections/VariantCollection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collections/VariantCollection.php b/src/collections/VariantCollection.php index fd381c32..2276b16d 100644 --- a/src/collections/VariantCollection.php +++ b/src/collections/VariantCollection.php @@ -37,7 +37,7 @@ public static function make($items = []) continue; } elseif (is_array($item)) { $item += ['class' => Variant::class]; - $item = \Craft::createObject($item); + $item = Craft::createObject($item); } elseif ($item instanceof ShopifyData) { $item = Craft::createObject([ 'class' => Variant::class, From 9f2d25b8a73c18b5d3c53b5bc2fd4dfde207b6e1 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 12 Aug 2026 16:32:41 -0700 Subject: [PATCH 62/65] Screenshot of OAuth flow Must have added this, locally, and never committed! Got stuck in git purgatory. :( --- README.md | 3 ++- docs/shopify-authorize-cp.png | Bin 0 -> 38712 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/shopify-authorize-cp.png diff --git a/README.md b/README.md index 4ed1afe8..256054f4 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ In this step, we’ll perform the [authorization code grant](https://shopify.dev > If you do not see a blue banner confirming **This app is exclusive to your store**, _do not proceed_! > A banner saying **This app can’t be installed on this store** (or landing on a generic Shopify error page) usually means that the hostname is not valid for the distribution. 1. You will be redirected to the Craft control panel “auth” URL you used when creating the Shopify app. (If you were not already logged in, Craft will ask for your username and password; your user must have the **Access Shopify** permission or be an administrator to complete the authorization flow.) -1. Press **Authorize** in the dialog. +1. Confirm the store’s hostname and press **Authorize** in the dialog: + ![Completing the OAuth flow in Craft](docs/shopify-authorize-cp.png) 1. Craft and Shopify will perform the OAuth handshake, and you should land on a confirmation screen in the Craft control panel saying **Your Shopify app has been successfully authorized**. 🎊 Congratulations! Your Craft project can now communicate with the Shopify API. diff --git a/docs/shopify-authorize-cp.png b/docs/shopify-authorize-cp.png new file mode 100644 index 0000000000000000000000000000000000000000..cac5abb259cec7c8b4daf2cfcacb59698b5abbf9 GIT binary patch literal 38712 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 Q0{D{@{UlQIQP=PP0K-yqKmY&$ literal 0 HcmV?d00001 From 133533d3060d1db523cf8769c749b885487eb522 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 13 Aug 2026 11:17:19 +0100 Subject: [PATCH 63/65] Tidy changelog --- CHANGELOG-WIP.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 4c346ed7..e6eb6f21 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -36,8 +36,6 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra - Added `craft\shopify\exceptions\ShopifyApiException`. - Added `craft\shopify\helpers\Metafield`. - Added `craft\shopify\helpers\ShopifyHelper`. -- Added `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyGid`. -- Added `craft\shopify\models\BulkOperation::$shopifyGid`. - Added `craft\shopify\models\Settings::REQUIRED_SCOPES`. - Added `craft\shopify\models\Settings::getAdditionalFeatures()`. - Added `craft\shopify\models\Settings::getAdditionalFeaturesOptions()`. @@ -68,7 +66,6 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra - Removed `craft\shopify\services\Api::getSession()`. Use `connect()` instead. - Removed `craft\shopify\services\Api::initializeContext()`. - Removed `craft\shopify\services\Api::WEBHOOK_TOPICS`. Use `getWebhookTopics()` instead. -- Removed `craft\shopify\events\DefineContextConfigEvent` and `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG`. ### System From 7b05773fe35b7dae64bf57f3fcc4faabe28155d1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 13 Aug 2026 16:08:03 +0100 Subject: [PATCH 64/65] Add ability to create tests for live Shopify API interaction --- tests/.env.example.mysql | 18 ++++- tests/.env.example.pgsql | 18 ++++- .../templates/shopify-collections-query.twig | 15 +++++ tests/_support/Helper/RequiresLiveApi.php | 67 +++++++++++++++++++ tests/unit/services/ApiLiveTest.php | 65 ++++++++++++++++++ tests/unit/twig/ApiQueryTemplateLiveTest.php | 59 ++++++++++++++++ 6 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 tests/_craft/templates/shopify-collections-query.twig create mode 100644 tests/_support/Helper/RequiresLiveApi.php create mode 100644 tests/unit/services/ApiLiveTest.php create mode 100644 tests/unit/twig/ApiQueryTemplateLiveTest.php diff --git a/tests/.env.example.mysql b/tests/.env.example.mysql index 2be345a8..89879803 100644 --- a/tests/.env.example.mysql +++ b/tests/.env.example.mysql @@ -12,4 +12,20 @@ DB_SCHEMA="public" # Set this to the `entryUrl` param in the `codeception.yml` file. DEFAULT_SITE_URL="https://test.craftcms.test/index.php" FROM_EMAIL_NAME="Craft CMS" -FROM_EMAIL_ADDRESS="info@craftcms.com" \ No newline at end of file +FROM_EMAIL_ADDRESS="info@craftcms.com" + +# Optional: credentials for tests tagged `@group live` (see tests/_support/Helper/RequiresLiveApi.php), +# which exercise the real Shopify API instead of fixtures. Point these at a Shopify Partner +# development store, not a production one. If any of these are unset, live tests are skipped — +# this is how they're skipped in CI, which has no access to real credentials. +# SHOPIFY_LIVE_ACCESS_TOKEN must come from an app that has already completed the plugin's OAuth +# authorization flow (see the README's "Connect to Shopify" section) — Shopify only issues access +# tokens after a real authorization, so this can't be an arbitrary string. +# SHOPIFY_LIVE_HOST_NAME=your-dev-store.myshopify.com +# SHOPIFY_LIVE_CLIENT_ID= +# SHOPIFY_LIVE_CLIENT_SECRET= +# SHOPIFY_LIVE_ACCESS_TOKEN= +# Optional: pins live tests to a specific Shopify API version instead of the plugin's default. +# Useful for checking the plugin against a version ahead of or behind the default, to catch +# API changes early. Must be one of craft\shopify\enums\ApiVersion's supported values. +# SHOPIFY_LIVE_API_VERSION= \ No newline at end of file diff --git a/tests/.env.example.pgsql b/tests/.env.example.pgsql index aa67c08c..ac36e4eb 100644 --- a/tests/.env.example.pgsql +++ b/tests/.env.example.pgsql @@ -12,4 +12,20 @@ DB_SCHEMA="public" # Set this to the `entryUrl` param in the `codeception.yml` file. DEFAULT_SITE_URL="https://test.craftcms.test/index.php" FROM_EMAIL_NAME="Craft CMS" -FROM_EMAIL_ADDRESS="info@craftcms.com" \ No newline at end of file +FROM_EMAIL_ADDRESS="info@craftcms.com" + +# Optional: credentials for tests tagged `@group live` (see tests/_support/Helper/RequiresLiveApi.php), +# which exercise the real Shopify API instead of fixtures. Point these at a Shopify Partner +# development store, not a production one. If any of these are unset, live tests are skipped — +# this is how they're skipped in CI, which has no access to real credentials. +# SHOPIFY_LIVE_ACCESS_TOKEN must come from an app that has already completed the plugin's OAuth +# authorization flow (see the README's "Connect to Shopify" section) — Shopify only issues access +# tokens after a real authorization, so this can't be an arbitrary string. +# SHOPIFY_LIVE_HOST_NAME=your-dev-store.myshopify.com +# SHOPIFY_LIVE_CLIENT_ID= +# SHOPIFY_LIVE_CLIENT_SECRET= +# SHOPIFY_LIVE_ACCESS_TOKEN= +# Optional: pins live tests to a specific Shopify API version instead of the plugin's default. +# Useful for checking the plugin against a version ahead of or behind the default, to catch +# API changes early. Must be one of craft\shopify\enums\ApiVersion's supported values. +# SHOPIFY_LIVE_API_VERSION= \ No newline at end of file diff --git a/tests/_craft/templates/shopify-collections-query.twig b/tests/_craft/templates/shopify-collections-query.twig new file mode 100644 index 00000000..3f35b6ec --- /dev/null +++ b/tests/_craft/templates/shopify-collections-query.twig @@ -0,0 +1,15 @@ +{# Mirrors the README's "API Service" `craft.shopify.api.query()` example. #} +{% set gql %} + { + collections(first: 10) { + nodes { + id + title + } + } + } +{% endset %} + +{% set response = craft.shopify.api.query(gql) %} +{% set collections = response.nodes ?? [] %} +{{- collections|length -}} diff --git a/tests/_support/Helper/RequiresLiveApi.php b/tests/_support/Helper/RequiresLiveApi.php new file mode 100644 index 00000000..a5856181 --- /dev/null +++ b/tests/_support/Helper/RequiresLiveApi.php @@ -0,0 +1,67 @@ +getSettings(); + $settings->setHostName($hostName); + $settings->setClientId($clientId); + $settings->setClientSecret($clientSecret); + $settings->setAccessToken($accessToken); + + if ($apiVersion) { + $supportedVersions = Plugin::getInstance()->getApi()->getSupportedApiVersions(); + if (!in_array($apiVersion, $supportedVersions, true)) { + self::fail(sprintf( + 'SHOPIFY_LIVE_API_VERSION "%s" is not one of the versions this plugin supports (%s).', + $apiVersion, + implode(', ', $supportedVersions), + )); + } + + $settings->setApiVersion($apiVersion); + } + } +} diff --git a/tests/unit/services/ApiLiveTest.php b/tests/unit/services/ApiLiveTest.php new file mode 100644 index 00000000..797ca319 --- /dev/null +++ b/tests/unit/services/ApiLiveTest.php @@ -0,0 +1,65 @@ +requireLiveApi(); + } + + public function testCanFetchShop(): void + { + $shop = Plugin::getInstance()->getApi()->getShop(true); + + self::assertIsArray($shop); + self::assertArrayHasKey('name', $shop); + self::assertArrayHasKey('myshopifyDomain', $shop); + self::assertNotEmpty($shop['name']); + } + + /** + * Fetches the webhook subscriptions registered for this plugin's webhook URL. Read-only — + * doesn't register or delete anything, so it's safe to run against a real store repeatedly. + * + * If the URL doesn't match any subscriptions registered in the store (e.g. a local test + * environment's computed URL), this legitimately returns an empty collection — that's not + * a failure on its own, just confirmation the call and response shape are correct. + */ + public function testCanFetchWebhooks(): void + { + $webhooks = Plugin::getInstance()->getApi()->getWebhooks(); + + self::assertInstanceOf(Collection::class, $webhooks); + + foreach ($webhooks as $webhook) { + self::assertArrayHasKey('id', $webhook); + self::assertArrayHasKey('topic', $webhook); + self::assertArrayHasKey('uri', $webhook); + self::assertStringStartsWith('gid://shopify/WebhookSubscription/', $webhook['id']); + } + } +} diff --git a/tests/unit/twig/ApiQueryTemplateLiveTest.php b/tests/unit/twig/ApiQueryTemplateLiveTest.php new file mode 100644 index 00000000..a52dd698 --- /dev/null +++ b/tests/unit/twig/ApiQueryTemplateLiveTest.php @@ -0,0 +1,59 @@ +requireLiveApi(); + } + + public function testCollectionsQueryTemplateFromReadmeExample(): void + { + // Mirrors the README's "API Service" example: `craft.shopify.api.query(gql)` + // against `collections(first: 10) { nodes { id title } }`. + $output = Craft::$app->getView()->renderTemplate('shopify-collections-query', [], View::TEMPLATE_MODE_SITE); + + self::assertMatchesRegularExpression('/^\d+$/', trim($output)); + + // Confirm the shape of what the template consumed, by running the same query directly. + $response = Plugin::getInstance()->getApi()->query('{ collections(first: 10) { nodes { id title } } }'); + $collections = $response['nodes'] ?? []; + + self::assertSame((string)count($collections), trim($output)); + + foreach ($collections as $collection) { + self::assertArrayHasKey('id', $collection); + self::assertArrayHasKey('title', $collection); + self::assertStringStartsWith('gid://shopify/Collection/', $collection['id']); + } + } +} From 8e110aaa7d6974b15a55621a3d90953119ef5013 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 20 Aug 2026 11:09:35 -0700 Subject: [PATCH 65/65] Changelog tweaks [ci skip] --- CHANGELOG-WIP.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index e6eb6f21..31963a68 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -8,24 +8,18 @@ This is primarily a maintenance release, focusing on Shopify API compatibility, authorization, and overall consistency. Developers should review their templates and extensions for potentially breaking changes to products’ and variants’ `shopifyId` property. -See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgrading) section in the readme for more information. +See [Upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgrading) for details. ### Store Management - Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215)) - It’s now possible to view the required API scopes in the plugin settings. - It’s now possible to extend the API scopes with opt-in additional features and custom scopes. -- Added support for the 2026-04 and 2026-07 Shopify API versions. -- Product inventory now also syncs when Shopify sends an `inventory_items/update` webhook. +- Added support for Shopify API versions `2026-04` and `2026-07`. +- Product inventory is now synced when Shopify sends `inventory_items/update` webhooks. ### Extensibility -- `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. -- `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. -- `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. -- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. -- `craft\shopify\elements\Product::setMetafields()` and `craft\shopify\models\Variant::setMetafields()` now require a list-shaped array of `{key, value}` objects (or a JSON-encoded string of the same), and throw `\InvalidArgumentException` for anything else. Previously, an associative `key => value` map was also accepted without validation. -- API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. - Added `craft\shopify\auth\OAuthFlow`. - Added `craft\shopify\clients\GraphqlClient`. - Added `craft\shopify\controllers\SettingsController::actionGetScopes()`. @@ -52,6 +46,12 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra - Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`. - Added `craft\shopify\services\Products::syncProductByShopifyGid()`. - Added `craft\shopify\webhooks\WebhookRegistry`. +- `craft\shopify\elements\Product::setMetafields()` and `craft\shopify\models\Variant::setMetafields()` now require a list-shaped array of `{key, value}` objects (or a JSON-encoded string of the same), and throw `\InvalidArgumentException` for anything else. Previously, an associative `key => value` map was also accepted without validation. +- `craft\shopify\handlers\Webhook::handle()` no longer implements `Shopify\Webhooks\Handler`, and its `$topic` argument is now a `craft\shopify\enums\WebhookTopics` enum instead of a string. +- `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`. +- `craft\shopify\services\Api::getGqlClient()` now returns a `craft\shopify\clients\GraphqlClient` instance instead of `Shopify\Clients\Graphql`. +- API and webhook errors are now thrown as `craft\shopify\exceptions\ShopifyApiException` and `craft\shopify\exceptions\InvalidOAuthException`, rather than the `Shopify\Exception\*` classes from the (now-removed) `shopify/shopify-api` package. - Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`. - Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`. - Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead. @@ -63,15 +63,15 @@ See the [upgrading](https://github.com/craftcms/shopify/blob/8.x/README.md#upgra - Removed `craft\shopify\models\Settings::getApiSecretKey()`. Use `getClientSecret()` instead. - Removed `craft\shopify\models\Settings::setApiKey()`. Use `setClientId()` instead. - Removed `craft\shopify\models\Settings::setApiSecretKey()`. Use `setClientSecret()` instead. +- Removed `craft\shopify\services\Api::WEBHOOK_TOPICS`. Use `getWebhookTopics()` instead. - Removed `craft\shopify\services\Api::getSession()`. Use `connect()` instead. - Removed `craft\shopify\services\Api::initializeContext()`. -- Removed `craft\shopify\services\Api::WEBHOOK_TOPICS`. Use `getWebhookTopics()` instead. ### System -- The `shopify_data` table's `shopifyId` column has been renamed to `shopifyGid`. A new generated `shopifyId` column (the numeric ID at the end of the GID) has been added. -- The `shopify_bulkoperations` table's `shopifyId` column has been renamed to `shopifyGid`. -- Fixed a bug where validation errors for the "Context Pricing Countries" setting weren't displaying correctly. -- Fixed a bug where `inventory_levels/update` webhooks weren't triggering a product sync. -- Removed the `shopify/shopify-api` Composer dependency. - Shopify for Craft now requires Craft CMS 5.10.7 or later. Craft 4 is no longer supported. +- The `shopify_data` table’s `shopifyId` column has been renamed to `shopifyGid`. A new `shopifyId` generated column has been added, set to the numeric ID at the end of the GID. +- The `shopify_bulkoperations` table’s `shopifyId` column has been renamed to `shopifyGid`. +- Removed the `shopify/shopify-api` Composer dependency. +- Fixed a bug where validation errors for the “Context Pricing Countries” setting weren’t displaying correctly. +- Fixed a bug where `inventory_levels/update` webhooks weren’t triggering a product sync.