Skip to content

6.x - #4124

Draft
lukeholder wants to merge 172 commits into
5.xfrom
6.x
Draft

6.x#4124
lukeholder wants to merge 172 commits into
5.xfrom
6.x

Conversation

@lukeholder

@lukeholder lukeholder commented Sep 23, 2025

Copy link
Copy Markdown
Member

Remaining: src-yii2/src/ Migration

High-level checklist of what's left to finish migrating Commerce from the legacy Yii2 codebase (src-yii2/, craft\commerce\*) to the new Laravel codebase (src/, CraftCms\Commerce\*).

Most of src-yii2/ is already migrated (thin class_alias()/delegation/extends wrappers). What's below is what's still a full legacy implementation with no src/ equivalent, grouped by domain. Templates and JS/Vue/SCSS assets are each tracked as a single task, not itemized.

TODO

Plugin.php bootstrap migration

  • (element/widget/link-type registries, method overrides, event listeners, permissions, CP nav, resave commands, behaviors → macros, GQL/GC/exports/twig registrations) — all done
  • Follow-up bug: Stores::afterDeleteCraftSiteHandler() null-pointer on single-store installs when reassigning primary store
  • Follow-up bug: ProductTypes::getViewableProductTypeIds() unguarded $user->can(...) call when no authenticated user
  • Design gap (needs a cms-6 core change, not just Commerce): no extension point for defineFields()/defineRules() equivalent on User/Address now that behaviors are gone — primaryBillingAddressId etc. can't be validated or serialized. @rias mentioned looking into ValidationRulesResolving

Condition-rule / query-builder system (biggest remaining chunk)

Craft's condition-builder system has no src/ equivalent yet beyond CatalogPricing's own rules. Blocks several other items below (Discounts, Sales, Zones, Gateways, GQL resolvers, the corresponding Pest tests).

  • Design + build the new-system condition/condition-rule base (mirroring whatever cms-6 provides for its own elements)
  • Migrate order conditions/rules (coupon code, customer, status, totals, paid, shipping, etc. — elements/conditions/orders/*)
  • Migrate product conditions/rules (elements/conditions/products/*)
  • Migrate variant conditions/rules (elements/conditions/variants/*)
  • Migrate purchasable conditions/rules (elements/conditions/purchasables/*, excluding the already-migrated CatalogPricing ones)
  • Migrate address conditions/rules (discount/gateway/zone/postal-code — elements/conditions/addresses/*)
  • Migrate customer conditions/rules (elements/conditions/customers/*)
  • Migrate user conditions/rules (discount group — elements/conditions/users/*)
  • Migrate transfer condition (elements/conditions/transfers/TransferCondition.php)

Gateways

  • Port Dummy/Manual/MissingGateway gateway driver implementations to src/Payment/Gateway/ (only Responses/, Records/, Contracts/ exist so far)
  • Port Base/Gateway.php + Base/GatewayTrait.php base classes
  • Port helpers/PaymentForm.php

GraphQL

  • Port Product/Variant GQL types, interfaces, resolvers, input types (gql/types/, gql/interfaces/, gql/resolvers/, gql/types/input/, gql/types/generators/)
  • Port Product/Variant GQL queries (gql/queries/*)
  • Port gql/arguments/elements/{Product,Variant}.php (currently still legacy-only)
  • Port helpers/Gql.php
  • Wire up _registerGqlInterfaces()/_registerGqlQueries()/_registerRelatedToArguments() (schema-registration half) once the above lands

Transfers

  • Migrate the whole Transfers domain to src/services/Transfers.php, elements/Transfer.php, elements/db/TransferQuery.php, fieldlayoutelements/TransferManagementField.php (currently only TransferDetail model is migrated)

Element actions & field-layout elements

  • Port element actions: CopyLoadCartUrl, CreateDiscount, CreateSale, DownloadOrderPdfAction, SetDefaultVariant, UpdateOrderStatus
  • Port field-layout elements: ProductTitleField, VariantTitleField, VariantsField, UserAddressSettings, TransferManagementField, and the Purchasable*Field classes (SKU, price, stock, weight, dimensions, allowed qty, available-for-purchase, free-shipping, promotable)
  • Port fields/Products.php and fields/Variants.php (custom field types)
  • Confirm linktypes/Product.php can be deleted now that src/Catalog/LinkTypes/ProductLinkType.php covers it

Behaviors (mostly dead code — needs cleanup, not porting)

Per Group 7 findings: Site/User/Address no longer support attachBehavior(), so these are already non-functional except where Yii2 classes are genuinely still Yii2 (CraftVariable).

  • CustomerBehavior/CustomerAddressBehavior/StoreBehavior — confirm nothing external still references them, then delete (functionality already replaced by macros in Group 7)
  • CurrencyAttributeBehavior — assess whether still needed / has a Laravel-native equivalent (casts?)
  • StoreLocationBehavior — assess and migrate or delete
  • ValidateOrganizationTaxIdBehavior — migrate to the new validation/Ruleset system

Console

  • Port console/controllers/{ExampleTemplatesController,GatewaysController,PricingCatalogController,ResetDataController,TransferCustomerDataController}.php to Laravel Artisan commands (src/Console/Commands/, following the ResaveCommand pattern already used for Groups 6)
  • Delete console/Controller.php base once all controllers above are ported

Helpers

  • helpers/Cp.php
  • helpers/Currency.php
  • helpers/Locale.php / helpers/Localization.php
  • helpers/Order.php
  • helpers/ProductQuery.php
  • helpers/ProjectConfigData.php
  • helpers/Purchasable.php
  • (Gql/PaymentForm helpers listed under GraphQL/Gateways above)

Base classes / traits

  • Base/InventoryItemTrait.php, Base/InventoryLocationTrait.php
  • Base/Model.php
  • Base/Stat.php, Base/StatTrait.php, Base/StatWidgetTrait.php
  • Base/StoreTrait.php
  • Base/TaxEngineInterface.php, Base/TaxIdValidatorInterface.php, Base/ZoneInterface.php

Twig / web

  • web/twig/Extension.php — port to src/, then simplify Plugin::boot()'s Twig::registerExtension() call site
  • web/twig/CraftVariableBehavior.php — stays legacy intentionally (real Yii2 CraftVariable, not aliased); revisit only if core changes that
  • Migrate all CP asset bundles (JS/Vue/SCSS, e.g. commercecp, commerceui, inventory, catalogpricing, coupons, transfers, etc.) to the Craft 6 asset pipeline (see docs/6.x/extend/assets.md)
  • Migrate all CP Twig templates under src-yii2/templates/ to the src/ template structure/rendering approach

Data layer

  • Port the remaining loose Yii2 migrations to database/migrations/ (Laravel migration format) alongside Install.php
  • validators/CouponsValidator.php — migrate to the new Ruleset/validation system

Translations

  • Port all locale translation files to the new bulk-per-locale export format (per docs/6.x/extend/translation.md), updating Craft::t('commerce', ...) call sites to t('...', category: 'commerce') as each domain migrates

Plugin routing/variables cleanup

  • plugin/LegacyRoutingModule.php, plugin/Routes.php (remaining rules deliberately staying — re-check only if core routing changes), plugin/Variables.php

Queue jobs

  • Migrate queue/jobs/{SendEmail,ResaveProductVariants,CatalogPricing}.php to native Laravel ShouldQueue jobs, then drop the LegacyJobWrapper shim and switch craft\helpers\Queue::push() call sites to the Illuminate\Support\Facades\Queue facade

Test infrastructure — port tests-yii2/ → Pest (tests/Feature, tests/Unit)

Suggested order per the migration plan, now that Plugin::boot()/register() fire correctly under Testbench:

  • Helpers — done (Currency, Locale, Localization)
  • Stats (AverageOrderTotal, NewCustomers, RepeatCustomers, TopCustomers, TopProducts, TopProductTypes, TopPurchasables, TotalOrders, TotalOrdersByCountry, TotalRevenue, base Stat) — low coupling, do next
  • Models (Sale, TaxRate, StoreSettings, LineItem minus its mock dependency) + Adjusters (Discount, Tax)
  • Services (bulk of the list — Carts, CatalogPricing, Coupons, Customers, Discounts, Inventory, LineItems, Orders, PaymentCurrencies, Sales, ShippingCategories/Methods/Rules, Stores, TaxCategories) — port alongside whatever src/ service work is active
  • Elements (Order, Product, Variant, Donation) + Controllers (Cart, Orders, EmailPreview, ShippingRules) — widest surface, do after services
  • Blocked until the condition-rule system lands in src/: DiscountTest, VariantQueryTest, the order/product condition-rule tests, ProductResolverTest
  • Blocked until a gateway driver exists in src/: GatewaysTest
  • GQL feature test (GqlCest.phptests/Feature/Gql/) — lowest priority, needs the full GQL stack wired up
  • tests-yii2/test/{fixtures,mockclasses}/* — port ProductFixture/mock Purchasable as needed by the ports above
  • Drop: DebugPanelHelperTest (feature removed, no replacement)

Then

  • Once src-yii2/ only contains class_alias()/thin-wrapper files, collapse remaining legacy namespace shims and deprecate craft\commerce\* per the plan
  • Sweep CHANGELOG-WIP.md for completeness against the final state before release

@lukeholder
lukeholder requested a review from a team as a code owner September 23, 2025 07:42
@lukeholder
lukeholder marked this pull request as draft September 23, 2025 07:42
….5 requirements

- Rename src/ to src-yii2/ for the legacy Yii2 codebase. The new
  src/ directory will contain Laravel-based CraftCms\Commerce code,
  introduced progressively in later commits.
- Bump composer requirements to Craft 6 (craftcms/cms 6.0.0-alpha.1)
  and PHP 8.5+.
- Update phpstan, rector, and .gitignore for the new layout.

This is a structural change only — no behaviour changes.
Element and base-class signature compatibility for Craft 6's new
abstract method signatures:
- Order::getRecalculationMode() — return null safely before init() runs
- Order::getLink() — return type ?\Illuminate\Support\HtmlString
- Product/Variant/Subscription::setEagerLoadedElements() —
  use \CraftCms\Cms\Element\Data\EagerLoadPlan
- Transfer::prepareEditScreen() — return
  \CraftCms\Cms\Http\Responses\CpScreenResponse|Response
- VariantCollection::make() — variadic, matching Illuminate\Collection
- Purchasable::__unset() — add string type hint and void return
- PaymentCurrency::safeAttributes() — declare array return type

Rector pass: remove redundant /** @inheritdoc */ docblocks across
src-yii2/; add #[\Override] in src/gql/ handlers; drop deprecated
setAccessible(true) calls from tests (redundant since PHP 8.1).
The Commerce debug panel relied on craft\debug\Module (Yii2 debug
module) which no longer exists in Craft 6. Removed entirely:
- src-yii2/debug/CommercePanel.php
- src-yii2/helpers/DebugPanel.php
- src-yii2/events/CommerceDebugPanelDataEvent.php
- src-yii2/views/debug/commerce/ (detail, model, summary views)
- _registerDebugPanels() and its onInit hook from Plugin.php
- All DebugPanel::prependOrAppendModelTab() calls from 19 controllers

Also wires up craft.commerce as a macro on CraftCms\Cms\Twig\Variables\
CraftVariable so it works with the Laravel-based Twig variable layer,
and migrates Plugin to the new CraftCms\Cms\Support\Facades\Updates
facade.
Introduce Pest as the test runner for new src/ code, alongside the
existing Codeception suite (which stays in place while src-yii2/ is
still active). New tests will live under tests/Unit and tests/Feature
following Pest conventions; src-yii2/ tests stay Codeception until
their corresponding classes are migrated.

Adds:
- tests/Pest.php — Pest bootstrap
- tests/TestCase.php, tests/UnitTestCase.php — base classes
- tests/Support/DatabaseLock.php — concurrency helper for parallel runs
- testbench.yaml — Orchestra Testbench config
- phpunit.xml.dist — Pest/PHPUnit config

(Composer dependency bumps for Pest/Testbench are folded into the
bootstrap commit.)
Move the dependency-free constants/enums to the new src/ tree first;
every later stage can then import from CraftCms\Commerce\.

New locations:
- craft\commerce\db\Table → CraftCms\Commerce\Database\Table
- craft\commerce\enums\InventoryTransactionType → CraftCms\Commerce\Inventory\Enums\InventoryTransactionType
- craft\commerce\enums\InventoryUpdateQuantityType → CraftCms\Commerce\Inventory\Enums\InventoryUpdateQuantityType
- craft\commerce\enums\LineItemType → CraftCms\Commerce\Order\LineItem\Enums\LineItemType
- craft\commerce\enums\TransferStatusType → CraftCms\Commerce\Transfer\Enums\TransferStatusType

Legacy classes become class_alias stubs that point at the new
locations, preserving backwards compatibility for existing imports.
Move the 11 plugin contracts to domain-organized Contracts/ namespaces.
With Stage 1 enums and these interfaces in place, the rest of the
migration can implement against the new types without touching the
old craft\commerce\base\* paths.

New locations:
- craft\commerce\base\AdjusterInterface → CraftCms\Commerce\Order\Adjuster\Contracts\AdjusterInterface
- craft\commerce\base\CatalogPricingConditionRuleInterface → CraftCms\Commerce\CatalogPricing\Contracts\CatalogPricingConditionRuleInterface
- craft\commerce\base\GatewayInterface → CraftCms\Commerce\Payment\Gateway\Contracts\GatewayInterface
- craft\commerce\base\HasStoreInterface → CraftCms\Commerce\Store\Contracts\HasStoreInterface
- craft\commerce\base\InventoryMovementInterface → CraftCms\Commerce\Inventory\Contracts\InventoryMovementInterface
- craft\commerce\base\PlanInterface → CraftCms\Commerce\Subscription\Contracts\PlanInterface
- craft\commerce\base\PurchasableInterface → CraftCms\Commerce\Purchasable\Contracts\PurchasableInterface
- craft\commerce\base\RequestResponseInterface → CraftCms\Commerce\Payment\Gateway\Contracts\RequestResponseInterface
- craft\commerce\base\ShippingMethodInterface → CraftCms\Commerce\Shipping\Contracts\ShippingMethodInterface
- craft\commerce\base\ShippingRuleInterface → CraftCms\Commerce\Shipping\Contracts\ShippingRuleInterface
- craft\commerce\base\StatInterface → CraftCms\Commerce\Stats\Contracts\StatInterface

Legacy interfaces become class_alias stubs.
Move all 56 event classes from craft\commerce\events into the new
domain-organized CraftCms\Commerce\*\Events namespaces. Event classes
adopt PHP 8 constructor property promotion for clean, typed initialization.

New locations group events by domain:
- CraftCms\Commerce\Catalog\Events
- CraftCms\Commerce\Email\Events
- CraftCms\Commerce\Inventory\Events
- CraftCms\Commerce\Order\Events
- CraftCms\Commerce\Payment\Events
- CraftCms\Commerce\Pdf\Events
- CraftCms\Commerce\Promotion\Events
- CraftCms\Commerce\Purchasable\Events
- CraftCms\Commerce\Report\Events
- CraftCms\Commerce\Shipping\Events
- CraftCms\Commerce\Store\Events
- CraftCms\Commerce\Subscription\Events
- CraftCms\Commerce\Tax\Events

Cancelable events (previously extending craft\events\CancelableEvent)
now use the CraftCms\Cms\Shared\Concerns\ValidatableEvent trait.

Legacy craft\commerce\events\* classes are replaced with class_alias
stubs pointing at the new classes.
Move the 11 helper classes from craft\commerce\helpers to
CraftCms\Commerce\Helpers, swapping internal Craft/Yii static helper
calls for CraftCms\Cms\* and Laravel equivalents (Url, Cp, Json,
StringHelper, etc.).

New locations:
- CraftCms\Commerce\Helpers\Cp
- CraftCms\Commerce\Helpers\Currency
- CraftCms\Commerce\Helpers\Gql
- CraftCms\Commerce\Helpers\LineItem
- CraftCms\Commerce\Helpers\Locale
- CraftCms\Commerce\Helpers\Localization
- CraftCms\Commerce\Helpers\Order
- CraftCms\Commerce\Helpers\PaymentForm
- CraftCms\Commerce\Helpers\ProductQuery
- CraftCms\Commerce\Helpers\ProjectConfigData
- CraftCms\Commerce\Helpers\Purchasable

class_alias stubs in src-yii2/helpers/ are deferred until the
services that depend on these helpers are migrated; the old
craft\commerce\helpers\* paths still work via the Yii2 autoloader.
Migrate the simplest models (scalar properties, no element ties)
from craft\commerce\models to domain-organized classes under src/.
New classes extend CraftCms\Cms\Component\Component and use
getRules() with Laravel validation syntax instead of Yii2's
defineRules().

Models:
- craft\commerce\models\Coupon → CraftCms\Commerce\Promotion\Models\Coupon
- craft\commerce\models\LineItemStatus → CraftCms\Commerce\Order\Models\LineItemStatus
- craft\commerce\models\PaymentCurrency → CraftCms\Commerce\Payment\Models\PaymentCurrency
- craft\commerce\models\PurchasableStore → CraftCms\Commerce\Purchasable\Models\PurchasableStore
- craft\commerce\models\Settings → CraftCms\Commerce\Settings
- craft\commerce\models\ShippingCategory → CraftCms\Commerce\Shipping\Models\ShippingCategory
- craft\commerce\models\TaxCategory → CraftCms\Commerce\Tax\Models\TaxCategory

Subscription/payment forms and gateway response models:
- craft\commerce\models\subscriptions\CancelSubscriptionForm → CraftCms\Commerce\Subscription\Forms\CancelSubscriptionForm
- craft\commerce\models\subscriptions\SubscriptionForm → CraftCms\Commerce\Subscription\Forms\SubscriptionForm
- craft\commerce\models\subscriptions\SwitchPlansForm → CraftCms\Commerce\Subscription\Forms\SwitchPlansForm
- craft\commerce\models\subscriptions\SubscriptionPayment → CraftCms\Commerce\Subscription\Models\SubscriptionPayment
- craft\commerce\models\responses\Dummy → CraftCms\Commerce\Payment\Gateway\Responses\Dummy
- craft\commerce\models\responses\Manual → CraftCms\Commerce\Payment\Gateway\Responses\Manual
- craft\commerce\models\responses\DummySubscriptionResponse → CraftCms\Commerce\Subscription\Responses\DummySubscriptionResponse

Legacy classes become class_alias stubs.
Models with lazy-loaded relations (typically via getter methods that
call into a service to fetch related models) move into domain-organized
classes under src/.

Models:
- craft\commerce\models\OrderNotice → CraftCms\Commerce\Order\Models\OrderNotice
- craft\commerce\models\OrderHistory → CraftCms\Commerce\Order\Models\OrderHistory
- craft\commerce\models\SiteStore → CraftCms\Commerce\Store\Models\SiteStore
- craft\commerce\models\ShippingRuleCategory → CraftCms\Commerce\Shipping\Models\ShippingRuleCategory

Payment forms:
- craft\commerce\models\payments\BasePaymentForm → CraftCms\Commerce\Payment\Forms\BasePaymentForm
- craft\commerce\models\payments\OffsitePaymentForm → CraftCms\Commerce\Payment\Forms\OffsitePaymentForm
- craft\commerce\models\payments\CreditCardPaymentForm → CraftCms\Commerce\Payment\Forms\CreditCardPaymentForm
- craft\commerce\models\payments\DummyPaymentForm → CraftCms\Commerce\Payment\Forms\DummyPaymentForm

CreditCardPaymentForm's Luhn check is converted from a Yii2 method
validator to a Laravel closure rule in getRules(); setAttributes() now
overrides the Validates trait's method for expiry parsing.

Legacy classes become class_alias stubs.
adjustments, and inventory movement models

Stage 5c — inventory items, catalog/product type sites, transfer
details:
- ProductTypeSite → CraftCms\Commerce\Catalog\Models\ProductTypeSite
- InventoryItem → CraftCms\Commerce\Inventory\Models\InventoryItem
- InventoryFulfillmentLevel → CraftCms\Commerce\Inventory\Models\InventoryFulfillmentLevel
- InventoryLevel → CraftCms\Commerce\Inventory\Models\InventoryLevel
- InventoryTransaction → CraftCms\Commerce\Inventory\Models\InventoryTransaction
- UpdateInventoryLevel → CraftCms\Commerce\Inventory\Models\UpdateInventoryLevel
- UpdateInventoryLevelInTransfer → CraftCms\Commerce\Inventory\Models\UpdateInventoryLevelInTransfer
- TransferDetail → CraftCms\Commerce\Transfer\Models\TransferDetail

Stage 5d — email, PDF, zones, adjustments, inventory movements,
plus shared infrastructure:
- Email → CraftCms\Commerce\Email\Models\Email
- Pdf → CraftCms\Commerce\Pdf\Models\Pdf
- OrderAdjustment → CraftCms\Commerce\Order\Models\OrderAdjustment
- TaxRate → CraftCms\Commerce\Tax\Models\TaxRate
- ShippingAddressZone → CraftCms\Commerce\Shipping\Models\ShippingAddressZone
- TaxAddressZone → CraftCms\Commerce\Tax\Models\TaxAddressZone
- base\Zone (abstract) → CraftCms\Commerce\Base\Zone
- base\InventoryMovement (abstract) + 6 InventoryMovement subclasses + DeactivateInventoryLocation → CraftCms\Commerce\Inventory\Models\

Adds CraftCms\Commerce\Store\Concerns\StoreTrait — shared storeId
helper for store-aware models. Updates InventoryItemTrait,
InventoryLocationTrait, and InventoryMovementInterface to reference
new namespaces. Old src-yii2/Base/StoreTrait marked @deprecated.

Legacy classes become class_alias stubs.
…logPricing

Four models from craft\commerce\models move to domain-organized classes
under src/:
- OrderStatus → CraftCms\Commerce\Order\Models\OrderStatus
- PaymentSource → CraftCms\Commerce\Payment\Models\PaymentSource
- InventoryLocation → CraftCms\Commerce\Inventory\Models\InventoryLocation
- CatalogPricing → CraftCms\Commerce\Catalog\Models\CatalogPricing

Key swaps:
- Cp::statusLabelHtml() → app(CraftCms\Cms\Cp\Html\StatusHtml::class)
- Html::encode() → htmlspecialchars(..., ENT_QUOTES | ENT_SUBSTITUTE)
- Db::uidsByIds() → DB::table(...)->uidsByIds() (Laravel query builder macro)
- craft\elements\Address → CraftCms\Cms\Address\Elements\Address
- craft\base\* contracts → CraftCms\Cms\Component\Contracts\*
- Craft::$app->getUser()->getIdentity()?->can() → request()->craftUser()?->can()
- HandleValidator → inline regex + reserved-word closure
- CurrencyAttributeBehavior dropped (Yii2-only)
- Craft::$app->getDeprecator() → CraftCms\Cms\Support\Facades\Deprecator

Legacy classes become class_alias stubs.
4 supporting interfaces

Three models:
- Sale → CraftCms\Commerce\Promotion\Models\Sale
- StoreSettings → CraftCms\Commerce\Store\Models\StoreSettings
- Transaction → CraftCms\Commerce\Payment\Models\Transaction

Four interfaces previously left at the legacy base/ path:
- base\TaxIdValidatorInterface → CraftCms\Commerce\Tax\Contracts\TaxIdValidatorInterface
- base\TaxEngineInterface → CraftCms\Commerce\Tax\Contracts\TaxEngineInterface
- base\ZoneInterface → CraftCms\Commerce\Base\ZoneInterface
- base\SubscriptionResponseInterface → CraftCms\Commerce\Subscription\Contracts\SubscriptionResponseInterface

Key swaps:
- new Query()->select()->from()->leftJoin()->where()->column()
  → DB::table()->leftJoin()->where()->pluck()->all() (Sale)
- Craft::$app->getFormatter()->asPercent() → I18N::getFormatter()->asPercent()
- Craft::$app->getAddresses()->getCountryRepository()->getList(language)
  → Addresses::getCountryRepository()->getList(app()->getLocale())
- Address::findOne($id) → Elements::getElementById($id, Address::class)
- Craft::$app->getElements()->saveElement() → Elements::saveElement()
- Conditions::createCondition() facade
- Transaction's hash generation moved from init() to __construct
- CurrencyAttributeBehavior dropped (Yii2-only)

Legacy classes become class_alias stubs.
The full shipping method class hierarchy moves to src/:
- craft\commerce\base\ShippingMethod (abstract) → CraftCms\Commerce\Shipping\Models\BaseShippingMethod
- craft\commerce\models\ShippingMethod → CraftCms\Commerce\Shipping\Models\ShippingMethod
- craft\commerce\models\ShippingMethodOption → CraftCms\Commerce\Shipping\Models\ShippingMethodOption

Key swaps:
- craft\base\Chippable/Colorable/Iconic/Statusable → CraftCms\Cms\Component\Contracts\*
- craft\enums\Color → CraftCms\Cms\Shared\Enums\Color
- NotImplementedException → \BadMethodCallException (inline)
- UniqueValidator → Rule::unique() (Laravel validation)
- AttributeTypecastBehavior dropped (Yii2-only)
- CurrencyAttributeBehavior / currencyAttributes() / getCurrency()
  dropped from ShippingMethodOption (Yii2-only)
- Json::decodeIfJson() → CraftCms\Cms\Support\Json::decodeIfJson()
- Conditions::createCondition() facade

ShippingMethodOrderCondition and ShippingMethodCustomerCondition
remain on the old craft\commerce\elements\conditions\* paths until
their dependencies are migrated.

Legacy classes become class_alias stubs.
craft\commerce\models\ShippingRule → CraftCms\Commerce\Shipping\Models\ShippingRule.

Key swaps:
- Json::decodeIfJson() → CraftCms\Cms\Support\Json::decodeIfJson()
- Conditions::createCondition() facade
- Yii2 attribute-based closure validators (addError()) → Laravel
  closures with $fail() pattern
- validateShippingRuleCategories method validator → inline closure in
  getRules() using the Validates trait's addModelErrors() helper
- $this->getAttributes() in getOptions() → $this->toArray()

ShippingRuleOrderCondition and ShippingRuleCustomerCondition stay on
the old craft\commerce\elements\conditions\* paths until their
dependencies are migrated. Order and ShippingRuleCategory record
references also stay on the old paths.

Legacy class becomes a class_alias stub.
CatalogPricingRule moves to src/:
- craft\commerce\models\CatalogPricingRule → CraftCms\Commerce\Catalog\Models\CatalogPricingRule

Key swaps:
- craft\base\Model → CraftCms\Cms\Component\Component
- Yii2 defineRules() → Laravel getRules() with Rule::in() for 'apply'
- I18N::getFormatter()->asPercent() / Conditions::createCondition() facades
- CraftCms\Cms\Support\Json::decodeIfJson()

Post-Stage 5 fixes:
- Fix infinite recursion in ShippingMethodOrderCondition,
  ShippingRuleOrderCondition, DiscountOrderCondition config() methods.
  $this->toArray(['storeId']) was calling getObjectVars() which triggers
  the PHP 8.4 $config property hook getter, recursing into config().
  Replaced with explicit ['storeId' => $this->storeId].

Also adds CraftCms\Commerce\Base\EnumHelpersTrait (companion to the
Stage 1 enums, missed at the time) and the WIP changelog covering
stages 1–5.

Legacy CatalogPricingRule becomes a class_alias stub.
- Switch ConditionRule::modifyQuery() param types from
  craft\elements\db\ElementQueryInterface | yii\db\QueryInterface to
  Illuminate\Contracts\Database\Query\Builder, matching the Laravel
  condition rule signature in 6.x.
- Affected: DiscountedItemSubtotalConditionRule,
  OrderCurrencyValuesAttributeConditionRule, OrderSiteConditionRule,
  ShippingMethodConditionRule.
- src-yii2/services/Taxes.php: minor adjustment alongside the above.
- Templates: guard discounts/sales _edit.twig against a crash when the
  shippingrulecategories table doesn't exist yet (Stage 1/2 setups).
Settings already lived at CraftCms\Commerce\Settings from Stage 5a, but
the legacy src-yii2/models/Settings.php still held the full Yii2
implementation. Now:
- src-yii2/models/Settings.php replaced with a class_alias stub
- src/Settings.php gains the setAttributes() override from the legacy
  class so deprecated Commerce-4 settings keys are silently stripped
  (preserves backward compatibility for project configs that still
  reference orderPdfFilenameFormat, autoSetNewCartAddresses, etc.).

DummyPlan moves to CraftCms\Commerce\Subscription\Models\DummyPlan.
Still extends the unmigrated craft\commerce\base\Plan; switches to the
new CraftCms\Commerce\Subscription\Contracts\PlanInterface argument
type.

Legacy classes become class_alias stubs.
craft\commerce\models\Store → CraftCms\Commerce\Store\Models\Store.

Key swaps:
- craft\base\Model → CraftCms\Cms\Component\Component
- craft\helpers\App::parseEnv() → CraftCms\Cms\Support\Env::parse()
- craft\helpers\App::parseBooleanEnv() → CraftCms\Cms\Support\Env::parseBoolean()
- craft\helpers\UrlHelper::cpUrl() → CraftCms\Cms\Support\Url::cpUrl()
- craft\models\Site → CraftCms\Cms\Site\Data\Site
- UniqueValidator → Rule::unique() on the stores table, ignoring the
  current record id
- Yii2 attribute-based closure validator for currency-change-when-
  orders-exist → Laravel closure rule with $fail() pattern
- Craft::$app->getDeprecator() → CraftCms\Cms\Support\Facades\Deprecator
- Craft::t('commerce', ...) → global t() with category
- Yii2 attributes() override (added name/settings) → fields() override
  (same purpose under the new serialization layer)
- Dropped EnvAttributeParserBehavior — the existing getXxx(bool $parse)
  pattern already handles env parsing on every accessor

ZoneAddressCondition, Order element, and the Store record stay on the
legacy craft\commerce\* paths until those are migrated.

Legacy class becomes a class_alias stub.
…iscount

Migrated craft\commerce\models\Discount → CraftCms\Commerce\Promotion\Models\Discount.

Key swaps:
- craft\base\Model → CraftCms\Cms\Component\Component
- Yii2 Query builder (relation loaders) → DB::table()->leftJoin()->pluck()->all()
- Conditions::createCondition() facade
- I18N::getFormatter()->asPercent()
- CraftCms\Cms\Support\Json::decodeIfJson()
- Yii2 defineRules() → Laravel getRules(); closure validators rewritten
  with the $fail() pattern; Rule::in() for categoryRelationshipType/appliedTo
- craft\elements\conditions\ElementConditionInterface →
  CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface
- DiscountOrderCondition / DiscountCustomerCondition / DiscountAddressCondition,
  Order element, DiscountRecord, Coupons service retained as legacy refs

Removed 5.x-deprecated API while migrating (per CLAUDE.md guidance):
- Discount::setExcludeOnSale() / getExcludeOnSale() (use $excludeOnPromotion)
- Settings::VIEW_URI_CUSTOMERS / VIEW_URI_PROMOTIONS / VIEW_URI_SHIPPING /
  VIEW_URI_TAX constants
- Store::setCountries() / getCountries() / getCountriesList() /
  getAdministrativeAreasListByCountryCode() / getMarketAddressCondition()
  (use the equivalents on Store::getSettings())

Legacy Discount becomes a class_alias stub.
craft\commerce\services\Currencies → CraftCms\Commerce\Services\Currencies.

This is the first service migrated under the new Craft 6 pattern (see
docs/6.x/extend/services.md): services are plain auto-loadable PHP
classes marked with #[\Illuminate\Container\Attributes\Singleton] so
Laravel's container reuses the instance. No Yii2 Component inheritance
on the new class. Preferred access:

    app(\CraftCms\Commerce\Services\Currencies::class)->getTeller(...)

Legacy access stays working via the existing
`Plugin::getInstance()->getCurrencies()` route — the old service in
src-yii2/ is reduced to a thin Yii2 Component that delegates every
method to the new singleton via app(). Once all callers move to app(),
the legacy wrapper can be deleted.

Behaviour is unchanged. init() moved to __construct(). Tellers are still
cached per-iso on the singleton.
…e\Services

craft\commerce\services\PaymentCurrencies → CraftCms\Commerce\Services\PaymentCurrencies.
#[Singleton] on the new class, plain PHP. Yii2 component declaration
in Plugin.php stays — the legacy wrapper at src-yii2/services/
PaymentCurrencies.php now delegates every method to the new singleton
via app().

Key swaps inside the new service:
- Yii2 craft\db\Query → Laravel DB::table()->select()->where()->get()
- Db::update(...) → DB::table()->where(...)->update(...)
- Craft::createObject(['class' => ..., 'attributes' => ...])
  → new PaymentCurrency((array) $row)
- craft\commerce\errors\CurrencyException → \RuntimeException (the
  Yii2 base exception isn't visible to phpstan / no longer relevant
  in the Laravel layer)

Removed convertCurrency() from the new service — deprecated in 5.0.0.
Kept on the legacy wrapper only, so the two unmigrated src-yii2/
callers (Order element, OrdersController) keep working; they'll move
to convert()/convertAmount() when their classes migrate.
…rvices

craft\commerce\services\TaxCategories → CraftCms\Commerce\Services\TaxCategories.

Key swaps:
- Yii2 Query → Laravel DB::table() + Schema facade for the icon/color
  column-exists check (replaces $db->getSchema()->getTableSchema()->getColumn())
- ArrayHelper::firstWhere/firstValue/map/getColumn → collect()->firstWhere/
  first/mapWithKeys/pluck()
- Craft::$app->getDb()->createCommand()->delete()/insert() → DB::table()->
  where()->delete() / DB::table()->insert()
- Craft::$app->getQueue()->push(new ResaveElements([...])) (Yii2 array-config
  job) → dispatch(new ResaveElements(elementType: ..., criteria: ...))
  (CraftCms\Cms\Element\Jobs\ResaveElements)
- Yii2 InvalidConfigException for "must have one default" → \RuntimeException

TaxCategoryRecord and softDelete() retained — the Yii2 record stays
until the records layer migrates.

Legacy class becomes a delegating Yii2 Component wrapper.
…ce\Services

craft\commerce\services\ShippingCategories → CraftCms\Commerce\Services\ShippingCategories.

Same patterns as TaxCategories:
- #[Singleton] on the new class, Plugin's Yii2 component declaration
  delegates via the wrapper at src-yii2/services/
- Yii2 Query → Laravel DB::table()/Schema facade
- ArrayHelper utilities → Collection / native array_diff
- Craft::$app->getQueue()->push(new ResaveElements([...])) →
  dispatch(new ResaveElements(elementType: ..., criteria: ...,
  updateSearchIndex: false))
- InvalidConfigException for "must have one default" → \RuntimeException

ShippingCategoryRecord, softDelete(), and the legacy Variant element
are retained. The purchasable-store fallback logic on product-type
removal (assigns affected purchasables to the default shipping
category) is preserved exactly.

Legacy class becomes a delegating Yii2 Component wrapper.
craft\commerce\services\TaxZones → CraftCms\Commerce\Services\TaxZones.

Same pattern as the previous 6a services: #[Singleton] new class,
delegating Yii2 Component wrapper at src-yii2/services/.

Swaps:
- Yii2 Query → Laravel DB::table()->select()->orderBy()
- Craft::createObject(['class' => TaxAddressZone, 'attributes' => $row])
  → new TaxAddressZone((array) $row)
- yii\base\Exception ("zone not found") → \RuntimeException

TaxZoneRecord, ZoneAddressCondition, and the legacy Plugin::getInstance()
->getStores()->getCurrentStore() lookup retained.
…Services

craft\commerce\services\ShippingZones → CraftCms\Commerce\Services\ShippingZones.

Mirrors the TaxZones migration: Yii2 Query → Laravel DB::table(),
Craft::createObject → new ShippingAddressZone((array) $row),
yii\base\Exception → \RuntimeException. Legacy class becomes a
delegating Yii2 Component wrapper.

This finishes Stage 6a (Store config services): Currencies,
PaymentCurrencies, TaxCategories, ShippingCategories, TaxZones,
ShippingZones — all behind app(CraftCms\Commerce\Services\* ::class).
Adds a "Stage 6a" section covering Currencies, PaymentCurrencies,
TaxCategories, ShippingCategories, TaxZones, ShippingZones — all six
now under CraftCms\Commerce\Services and accessed via app(). Captures
the cross-cutting swaps applied (Yii2 Query → DB::table(), createObject
→ new, ArrayHelper → Collection, etc.).

Also records that PaymentCurrencies::convertCurrency() (deprecated in
5.0.0) was dropped from the new service but kept on the legacy wrapper
for the two unmigrated src-yii2/ callers.
CraftCms\Cms\Plugin\Plugins::installPlugin('commerce') calls loadPlugins()
as its own first line, before Commerce has a row in the plugins table yet.
Finding nothing to register, loadPlugins() still permanently sets its
internal pluginsLoaded guard to true. Since Plugins is a container
singleton, every later loadPlugins() call for the rest of the test run
short-circuits immediately -- including the one that would otherwise pick
up Commerce moments later in the same install flow. The net effect: every
boot()-registered feature (GQL argument handlers, widgets, permissions, CP
nav, resave commands, event listeners, Macroable macros) was invisible to
the Pest suite, verified only by hand via tinker against the live app.

Root-caused via a sequence of diagnostic Pest tests (not guesswork):
getPlugin('commerce') returned null despite isPluginInstalled()/
isPluginEnabled() both true; manually calling createPlugin() in isolation
worked fine, ruling out the class/manifest as the problem.

Fix: forget the Plugins singleton and reload it right after installing
Commerce in tests/TestCase.php, so the next resolution re-scans the
now-populated plugins table and actually registers craft\commerce\Plugin
as a Laravel service provider. Same fix shape core's own
CraftCms\Cms\Plugin\Testing\InstallsPlugin trait already applies.

Added tests/Feature/PluginBootTest.php as durable regression coverage,
confirming a Group-1 boot() registration and the Group-7 Site::getStore()
macro (both method-call and magic-property syntax) now resolve correctly.
52/52 tests passing (49 existing + 3 new).
…d along the way

Inventory CP:
- selectableSites() needs an array, not a Collection
- editLocationLevels() return type didn't cover the CpScreenResponse case
- redirect to the default inventory location was missing the CP trigger prefix

Install migration:
- port a real down() (drop tables, field layouts, project config) — it was
  previously just a stub, so uninstall silently left the plugin's data behind
- fix an FK identifier over MySQL's 64-char limit

Ported all tests-yii2/unit/stats/*, tests-yii2/unit/helpers/{Currency,Locale,
Localization}Test.php to Pest, and removed the now-redundant legacy DebugPanel
helper test (the DebugPanel feature itself was already removed). Added
tests/Support/OrdersFixture.php to build the order/product/customer graph the
stats tests assert against.

Real bugs surfaced while building out the stats fixture (all pre-existing,
not test-only):
- Variant::getSnapshot() included a `ruleset` validation object that
  circularly referenced its own product, crashing JSON encoding on any
  order line item
- LineItems.php was missing a use import for LineItemStatuses
- LineItemStatuses + 5 sibling services fired legacy Yii2 events
  unconditionally, crashing the first time they actually ran
- 9 legacy condition classes narrowed defineRules() visibility against a
  now-public parent, a PHP fatal
- Order::afterSave() didn't convert an already-set dateOrdered to UTC before
  storage, breaking timezone-dependent date filtering
- TopPurchasables ordered by an ambiguous `sku` column

Plus SQLite compatibility for the test suite (Stat.php chart queries,
CatalogPricing's UUID/NOW() raw SQL — now extracted into src/Helpers/Sql.php),
and two test-harness gaps (missing auth.guards.craft config, Solo edition's
1-user cap blocking fixture users).
… directly instead of the shared craftcms/.github workflow

Track 6.x instead of 5.x, target PHP 8.5, and split tests into Unit/Arch/Feature jobs via a shared run-tests composite action.
@lukeholder
lukeholder removed the request for review from a team August 19, 2026 10:07
Ports 5.x bugfixes (up to 5.7.2) into the Laravel-migrated 6.x code:
- Transfers field layout save/delete used Order::class instead of Transfer::class (src-yii2/services/Transfers.php)
- PDF/cart load URLs generated from console requests returned blank (src/Pdf/Pdfs.php, src/Order/Carts.php)
- Completed orders could have their recalculation mode reset to "all" (src/Order/Elements/Order.php)
- Inactive carts' searchindex rows weren't purged due to delete ordering (src/Order/Carts.php)
- Inventory location IDs per store weren't memoized (src/Inventory/InventoryLocations.php)

New 5.x Codeception unit tests (ShippingTest, OrderRecalculationTest) added as
reference material under tests-yii2/unit/, matching how the rest of the retired
Yii2/Codeception suite is archived on 6.x.
CatalogPricing.php, Customers.php, Emails.php, and Carts.php referenced
classes under the wrong namespace with no matching use import
(CatalogPricingRule, Carts, Pdfs, PaymentCurrencies) — real bugs that
would fatal at runtime if the code paths executed.

ProductQuery::cleanseQueryCriteria() gated its criteria-sanitizing on
`Craft::$app->controller instanceof ElementIndexesController` using
Yii2 controller classes that no longer exist anywhere in Craft 6.
Migrated the check to Laravel routing (request()->route()->getControllerClass())
against the new ElementIndexController/SearchController classes, matching
the pattern already used in cms-6's EnsureInstalled middleware.
…Line)

These no longer suppressed any actual error - PHPStan flags a bare
@phpstan-ignore-next-line as unmatched once the underlying error it was
written for has been fixed elsewhere during the migration. Verified each
removal leaves zero errors on that line before committing; total PHPStan
errors 1306 -> 1078.
…igrations/; remove dead legacy Install migration

Plugin::getMigrationsPath() (yii2-adapter) resolves a plugin's migrations
directory to dirname(getBasePath())/database/migrations when that directory
exists - src-yii2/migrations/ was never in the scan path. These 6 files were
already rewritten as genuine Laravel migrations (see b1b20f8) but landed in
the wrong directory, so they've never actually run against any 6.x install:
product type permission renaming, the commerce_catalogpricing_queue table,
orders.customerDeleted, the subscriptions userId FK cascade fix,
ordernotices.noticeType, and the allVariants->variants changedattributes fix.

Verified via `php craft migrate/all --track=plugin:commerce --pretend` that
all 6 are now discovered and their SQL is valid against the current schema.

Also removes src-yii2/migrations/Install.php: fully superseded by
database/migrations/Install.php (verified matching schema for producttypes),
and per docs/extend/migrations.md, once a plugin extends
CraftCms\Cms\Plugin\Plugin, only native Laravel migrations run - the old
Yii2-style Install migration was already unreachable dead code.
… legacy Order alias

craft\commerce\base\Gateway implements GatewayInterface, which is itself a
class_alias to the new CraftCms\Commerce\Payment\Gateway\Contracts\GatewayInterface.
PHP's LSP compatibility check for that implements clause needs to resolve
Gateway::availableForUseWithOrder()'s craft\commerce\elements\Order parameter
type - which is itself only a class_alias to the new Order class, created
lazily on first reference.

If nothing has touched craft\commerce\elements\Order yet, resolving it happens
reentrantly, mid-compatibility-check, while PHP is already inside the include()
for Gateway.php - which reliably fails with "class ... is not available" rather
than actually resolving it. Reproduced independent of PHPStan with a plain
`class_exists('craft\commerce\base\Gateway')` after only requiring the
autoloader.

Force-resolving the Order alias as a plain top-level statement, before the
class Gateway declaration is reached, avoids the reentrant path entirely.
Commerce's Model/Record classes (ProductType, Order, LineItem, Store,
OrderQuery, Email, Pdf, Transaction, Purchasable, etc.) rely on Eloquent's
magic __get/__set for every column, which plain phpstan/phpstan has no way
to type. That's the source of the large majority of PHPStan's
property.notFound/method.notFound/staticMethod.notFound noise.

Mirrors cms-6's phpstan.neon: includes larastan/larastan +
nesbot/carbon's extension, and points databaseMigrationsPath at
database/migrations (Install.php plus the 6 migrations moved there in the
previous commit) so Larastan can infer real column types instead of
requiring @Property annotations on every model.

property.notFound/method.notFound/staticMethod.notFound: 511+295+100 -> 95+120+15.
Total PHPStan errors: 1078 -> 429.
…rastan

Same cleanup as before, for @phpstan-ignore-next-line comments that only
became unmatched once Larastan resolved the underlying property/method
errors they were suppressing. Total PHPStan errors: 429 -> 422.
Real fixes:
- Log::error($e) passed a raw exception where Logger::error() needs a
  string/Arrayable/Stringable message - switched to
  Log::error($e->getMessage(), ['exception' => $e])
- Order\Models\Order was missing datetime casts for datePaid,
  dateFirstPaid, dateAuthorized, dateCreated, dateUpdated, so assigning
  the element's real DateTime values onto the persistence model was
  statically (and would eventually be a real) type mismatch; added the
  casts and convert via Carbon::instance() at the assignment sites
- getLineItems() ran array_filter() over a strictly LineItem[]-typed
  array, which can never contain falsy values - dead code, removed
- clearNotices()/_filterNotices() had redundant "other side is not
  null" checks in an exhaustive if/elseif chain, provably always true
  given the preceding branches - simplified

Docblock/type annotations (no behavior change):
- Added missing @property-read entries for the *AsCurrency accessors on
  Order and LineItem, and @Property for OrderNotice::$noticeType (now
  backed by the noticeType column from the migration moved in a
  previous commit)
- Added @var AddressElement assertions where Elements::duplicateElement()
  is called through the facade, which loses its generic <T> narrowing
  through the @method docblock and returns plain ElementInterface

Suppressions, for genuine PHPStan/Larastan blind spots (not real bugs):
- User::getPrimary{Shipping,Billing}Address()/getPrimaryPaymentSource()
  are added via the legacy CustomerBehavior at runtime, invisible to
  static analysis
- Several Order::trigger()/LineItem trigger() calls pass new-namespace
  event objects into the still-Yii2 trigger() signature - matches the
  "TODO: migrate event firing to Laravel" pattern already used elsewhere
- Two nullsafe.neverNull findings were false positives (getCustomer()
  and firstWhere() are both genuinely nullable) - changing to non-null
  access would introduce a real null-pointer bug
- Gateway/GatewayInterface class_alias-chain blind spot (same one
  already documented in GatewayTypes.php) hit two more call sites
- Conditions::createCondition()'s InvalidArgumentException is real
  (verified in cms-6) but invisible to PHPStan through the facade's
  __callStatic dispatch - catch is not dead
- CraftCms\RulesetValidation\Ruleset's template T isn't covariant, so
  PHPStan can't verify any ElementRules subclass against the #[Ruleset]
  attribute - same limitation cms-6's own AssetRules/EntryRules/UserRules
  have no workaround for either

Total PHPStan errors: 422 -> 360.
…l addError() runtime bug

phpstan.neon was missing the whole stubFiles list that cms-6's own config
includes (yii/base/Component, yii/validators/Validator, yii/db/*, etc.) -
these carry the accurate typed signatures for legacy Yii2 APIs (e.g.
Validator::addError() accepting craft\base\ModelInterface, not just the
untyped vendor source). Total PHPStan errors: 360 -> 259.

Purchasable.php:
- getLineItemRules()'s inline validators called
  $validator->addError($lineItem, $attribute, $message), routing through
  yii\validators\Validator::addError() which internally calls
  $lineItem->addError($attribute, $message) - but LineItem is a new
  Component/Validates class with no such method, only errors(): MessageBag.
  This was a real bug: any of these six validators actually firing (out of
  stock, invalid purchasable, qty limits, etc.) would fatal at runtime with
  "Call to undefined method LineItem::addError()". Switched to
  $lineItem->errors()->add($attribute, $message), matching the pattern
  already used throughout Order.php/Product.php.
- Localization::normalizeNumber() was called via
  CraftCms\Commerce\Helpers\Localization, which only has
  normalizePercentage() - normalizeNumber() is a Craft core helper
  (craft\helpers\Localization). Fixed the import.
- Added @property/@property-read docblock for storeId, basePrice,
  basePromotionalPrice, sku, taxCategoryId, shippingCategoryId, and the
  *AsCurrency accessors.
- getSku() had a dead `?? ''` fallback on a non-nullable string property.

Also removed two @phpstan-ignore-next-line comments (on Order's and
Purchasable's #[Ruleset(...)] attributes) that the stub fix made stale.

Total PHPStan errors: 259 -> 251.
…ressions

Real bugs (all would fatal at runtime on the affected code paths):
- loginHandler() referenced User::IMPERSONATE_KEY, a constant that
  doesn't exist anywhere in the codebase - Craft 6's impersonation
  session state moved to a dedicated CraftCms\Cms\Auth\Impersonation
  service. Switched to app(Impersonation::class)->isImpersonating().
  This ran on every user login.
- activateUserFromOrder() called $user->setScenario(Element::SCENARIO_ESSENTIALS)
  using scenario constants that live on ElementRules, not Element (and via
  craft\base\Element, itself just a class_alias to the new Element) - Craft
  6's scenario API is $element->ruleset->useScenario(...), not
  setScenario(). This ran on every guest checkout with "create an account"
  enabled.
- The same method used Event::once(...), which has never existed on
  yii\base\Event (only on(), off(), trigger(), etc.) - replaced with the
  standard self-removing on()/off() handler pattern.

Dead code removed: two property_exists($user, 'affiliatedSiteId') guards -
affiliatedSiteId is a real, always-present property on Craft 6's User now.

Suppressions, for the same CustomerBehavior/CustomerAddressBehavior
runtime-attached-method blind spot already documented in Order.php - not
real bugs, PHPStan just can't see methods added via legacy Yii2 behaviors.

Also suppressed one nullsafe.neverNull false positive (getBillingAddress()/
getShippingAddress() are genuinely nullable, matching the pattern already
found in Order.php).

Total PHPStan errors: 251 -> 229.
Corrected 13 @PHPStan-Ignore comments (in Order.php and Customers.php,
added in the previous two commits) that incorrectly attributed
User/Address methods to "the legacy CustomerBehavior/CustomerAddressBehavior,
attached at runtime". Plugin.php's registerBehaviorMacros() docblock
clarifies those behaviors "no longer attach to anything" post-migration -
Site/User/Address extend CraftCms\Cms\Component\Component, not
yii\base\Component, so attachBehavior() doesn't exist on them at all.
The real mechanism is Macroable macros registered in
Plugin::registerCustomerMacros()/registerCustomerAddressMacros(). Fixed
the comment text to name the real mechanism instead.

OrdersController.php fixes:
- currentUser()?->id read a nonexistent property on the CraftUser
  contract (only getCraftUserId() exists) - silently wrote null into
  $movement->userId on every inventory fulfillment movement instead of
  the actual current user.
- getPaymentModal() never null-checked $order after getOrderById(),
  then called methods on it unconditionally - added an abort_unless()
  guard matching the rest of the controller's pattern.
- $child->order->updateOrderPaidInformation() after capture/refund
  could hit a null Order (Transaction::$order is genuinely nullable) -
  switched to nullsafe.
- Simplified two provably-dead conditions (getFieldLayout() is
  overridden non-nullable on Order; $qty's `?? 1` already eliminates
  null) and made two abort_unless() truthy-checks explicit.

Also added the missing Transaction::$order @property-read docblock and
Order::$outstandingBalanceAsCurrency, and suppressed the remaining
Site::getStore()/Gateway class_alias-chain blind spots using the
corrected macro explanation.

Total PHPStan errors: 229 -> 209.
…ives

Real bug: defineTableAttributes() merged in 'product'/'isDefault'/'promotable'
as flat translated strings instead of ['label' => string] arrays - every
other definer in this file (defineCardAttributes()) and the base
DisplayedInIndex::defineTableAttributes() implementation use the wrapped
shape. Fixed to match.

Suppressed three more nullsafe.neverNull/nullCoalesce.expr findings that
are the same class of false positive already seen in Order.php/Customers.php:
getOwner() is declared ?ElementInterface and variantTitleTranslationMethod is
an uncast free-form DB string column, so tryFrom() genuinely can return null.
Discovered along the way that @phpstan-ignore-next-line only covers errors
reported on the literal next physical line, not later lines within the same
multi-line statement - switched those specific cases to a trailing
@phpstan-ignore-line comment on the actual offending line instead.

One nullsafe chain (getProductTypeHandle()) was simplified rather than
suppressed, since Product::getType(): ProductType is a verified
non-nullable override.

Total PHPStan errors: 209 -> 199.
normalizeLineItemPurchasableAvailability() cast $lineItem->getPurchasable()
(genuinely ?PurchasableInterface) to a non-nullable @var PurchasableInterface,
masking the null check right below it from static analysis. Worse, the
elseif branch called hasInventory()/getIsOutOfStockPurchasingAllowed()/
getStock()/inventoryTracked, all specific to the concrete Purchasable
element class, not part of PurchasableInterface's contract - a third-party
PurchasableInterface implementation that doesn't extend Purchasable would
have fataled here. Fixed the @var annotation to be nullable and added an
instanceof Purchasable guard before the stock-specific calls.

Also fixed two missing `use function CraftCms\Cms\t;` imports (function
t() not found).
…e docblocks

- Added @property-read $price/$promotionalPrice/$salePrice to Purchasable
  (fixes Variant::$price undefined-property error in getDefaultPrice()
  and covers every other Purchasable subclass too).
- Same tryFrom()-on-uncast-string-column suppressions as Variant.php, for
  productTitleTranslationMethod/slugTranslationMethod.
- updateTitle() had a defensive "check for null just in case the value
  comes back as 1, 0, true or false" - hasProductTitleField is a NOT NULL
  boolean column properly cast by Larastan; the null check was dead.
…findings

sendEmail() had a leftover "make sure date vars are in the correct format"
loop converting Order's dateOrdered/datePaid/dateFirstPaid to DateTime if
they weren't already - a legacy carry-over from when these were raw DB
strings. They're now plain `?DateTime` typed properties on the Order
element, so the loop's condition (instanceof DateTime check combined with
a truthy check) was provably always false - dead code, removed.

Suppressed one more TODO-marked trigger() event-bridging call and one
nullsafe.neverNull finding ($renderSite is genuinely nullable, from a
ternary that explicitly assigns null in its else branch).
…y suppressions

Real bug: the multi-currency conversion branch in createTransaction() passed
whole PaymentCurrency model objects into getTeller()/convertAmount(), both
of which are typed Money\Currency|string - under strict_types this would
throw a TypeError, not silently coerce. Fixed to pass the ->iso string,
matching how the rest of the method already dereferences ->iso.

Everything else in both files is the same legacy craft\commerce\base\Gateway
class_alias-chain blind spot already established (paymentType,
completePurchase/completeAuthorize/capture/supportsRefund/supportsPartialRefund/
supportsCapture/refund, and one @var Gateway downcast PHPStan can't verify),
plus one more TODO-marked trigger() event-bridging call, plus a
craft\commerce\models\Transaction class_alias PHPStan can't trace either.
…block conflict

The macro closures in registerCustomerMacros()/registerCustomerAddressMacros()
call other macros registered on the same class (e.g. getPrimaryBillingAddress()
calling getPrimaryBillingAddressId()) - PHPStan can't trace Macroable
dispatch even within its own registration site.

The three orderBy() "arguments.count" errors are a genuine cms-6 issue:
ElementQuery's class docblock has `@method static orderBy($column)` (1 param)
which conflicts with its own real `orderBy($column, $direction = 'asc')`
method (2 params) - PHPStan prioritizes the stale docblock tag. Not
something to fix here since ElementQuery is owned by cms-6; suppressed
with an explanation instead.
…me() false-path suppressions

Real fix: $number = str_replace(...) then $number-- decremented a plain
string rather than a numeric type - cast to (int) first.

Every DateTimeHelper::toDateTime()/strtotime() result flowing through this
file only fails for an unparseable input, and every input here is either a
hardcoded relative-date string ('first day of this month', etc.) or an
already-validated Y-m-d string - the DateTimeInterface|false union is
real per the signature but practically unreachable here. Suppressed rather
than adding defensive checks for a case that can't happen.

groupByRaw()/orderByRaw() SQL fragments are built entirely from server-side
driver/timezone detection in getChartQueryOptionsByInterval(), never from
user input, so PHPStan's literal-string requirement (an anti-SQL-injection
check) doesn't apply here either - suppressed with the same reasoning.
…ro/facade suppressions

Real bug: `use Illuminate\Http\Response as JsonResponseAlias;` was importing
the wrong class - JsonResponse does NOT extend Response (they're siblings,
both extending different Symfony HttpFoundation base classes separately),
so filter()/prices() were declared to return the aliased Response while
actually returning response()->json(...) (a real JsonResponse). Fixed the
import and both return types to the real Illuminate\Http\JsonResponse.

Everything else is the established Site::getStore() macro suppression
pattern, plus a @var CatalogPricingCondition assertion where
Conditions::createCondition() loses its narrow type through the facade's
__callStatic dispatch (same category as the Elements::duplicateElement()
facade-narrowing issue fixed earlier in Order.php).
…er-input date bug plus established patterns

Real bug: ProductsController::create() assigned
DateTimeHelper::toDateTime($request->input('postDate'/'expiryDate')) directly
to Product's ?DateTime properties - unlike the Stat.php/Order.php cases fixed
earlier, these inputs are genuinely user-controllable (CP form fields), so a
malformed date string would return false and, under strict_types, throw a
TypeError. Added a real fallback (now() / null) and converted to a concrete
DateTime instance.

Donation.php had the same $validator->addError($lineItem, ...) bug already
found and fixed in Purchasable.php (LineItem has no addError() method) -
switched to $lineItem->errors()->add(). Also added the missing
Donation model datetime casts (same gap as Order's model, fixed earlier)
and converted afterSave()'s date assignments through Carbon::instance().

LineItem was missing @Property float $price (writable, has get/setPrice())
entirely - added.

Everything else is abort_unless() truthy-to-explicit-null-check cleanup and
the established Gateway class_alias-chain suppressions.
…suppressions

Real bug (severe): Helpers/Cp.php, Currency.php, and Purchasable.php all
imported CraftCms\Cms\Cp\Cp - a tiny unrelated class with only config()/
vite() static methods. It has nothing to do with form-field HTML. Every
call in these three files (fieldHtml, textHtml, moneyInputHtml,
lightswitchHtml, renderTemplate) would fatal with "undefined static method"
- this is the entire Commerce CP UI for tax zones, tax categories, shipping
categories, inventory locations, money inputs, and price tables. Fixed the
imports to the legacy craft\helpers\Cp bridge, which has all of these
methods under the same names.

Real bug: request()->craftUser()?->id / currentUser()?->id read a
nonexistent property on the CraftUser contract (only getCraftUserId()
exists) in Inventory.php, InventoryLocations.php, and
OrderStatusesController.php - same category of bug already found and fixed
in OrdersController.php, silently writing null instead of the real user ID.

Real fix: LineItemStatuses::handleArchivedLineItemStatus() formatted a date
to a DB string via CraftDb::prepareDateForDb() and assigned it to a
Carbon-cast property - simplified to Carbon::now() directly, avoiding an
unnecessary format/reparse round-trip.

Real fix: Inventory::reduceInventoryForOrder() (executeInventoryMovements
loop) called hasInventory()/getInventoryLevels(), both specific to the
concrete Purchasable class, on a PurchasableInterface-typed variable with
no instanceof guard - same category of bug already fixed in
Helpers/Order.php and Donation.php.

Everything else is the established Site::getStore() macro suppression,
TODO-marked trigger() event-bridging suppressions, OrderAdjustment missing
its $sourceSnapshot docblock, and two provably-dead null checks
(Discount::$baseDiscount is a non-nullable float, InventoryMovementCollection
already yields the concrete InventoryMovement type without needing a
downcast @var).
Real bug: CatalogPricingRulesController's edit screen called ->tabs([...])
with a plain numeric-indexed list of tab configs, but CpScreenResponse::tabs()
requires string keys matching each tab's container id (confirmed against
every other controller's ->tabs() call in this codebase, e.g.
ProductTypesController) - this would have rendered broken/non-functional
tabs in the CP. Fixed to use 'rule'/'conditions'/'actions' keys.

Same Carbon::instance()/DateTime::createFromInterface() conversions as the
Order/Donation fixes earlier, now for CatalogPricingRule, Discount, Sale,
and Gateway records/models, both at the record-save sites (domain model's
plain ?DateTime -> Carbon-cast record property) and the controller
user-input sites (DateTimeHelper::toDateTime() can genuinely return false
for a malformed date).

Two LineItemStatuses/Gateways-style CraftDb::prepareDateForDb(new DateTime())
"archive now" assignments simplified to Carbon::now() directly.

Added the missing CatalogPricingRule::$applyAmountAsPercent/$applyAmountAsFlat
docblock. Simplified one more redundant elseif branch in Discounts.php
(same provably-redundant-given-preceding-if pattern fixed in Order.php
earlier), and removed one incorrect Gateway suppression that turned out to
be unnecessary once the return type was already narrow enough.
…other real bugs

Real bug (severe): inventoryLevelsTableData() built its query entirely with
Yii2 query-builder syntax (andWhere() with nested condition arrays like
['not', [...]] and ['or', [...], [...]], [[bracket]]-quoted columns,
addGroupBy(), addOrderBy()) against a genuine Laravel Illuminate\Database\
Query\Builder returned by Inventory::getInventoryLevelQuery(). None of
that syntax exists on Laravel's builder - this is the main data-table
endpoint for the Inventory CP screen, so it would have fatally errored on
every page load. Converted to real Laravel builder calls (where(), a
where() closure for the OR/LIKE search, whereNotNull(), groupBy(), orderBy(),
a proper leftJoin() with column args), replaced ->all() with ->get() and the
limit(null)/offset(null) reset trick with getCountForPagination(), and fixed
the resulting Collection-vs-array usage (array_column -> pluck/filter/unique).

Also fixed two CpModalResponse-returning modal actions
(editUpdateLevelsModal/editMovementModal) that had an early "live preview"
return path returning a real JsonResponse but were typed to return only
CpModalResponse/Response - widened to CpModalResponse|JsonResponse.

InventoryLocationsController: setAttributes() called with a second `false`
"safeOnly" argument that no longer exists on the new Validatable contract -
confirmed the new setAttributes() always behaves like the old safeOnly=false
mode, so dropping the arg preserves behavior. Also fixed a facade-narrowing
Address lookup to handle a genuinely-possible null (stale/deleted address id)
instead of asserting non-null.

abort_unless()/abort_if() truthy-check cleanup across CartController,
DownloadsController, ProductTypesController, and TransfersController
(array|string|null / object|null params made explicit), plus two provably-dead
checks in CartController (LineItem::$qty is a non-nullable int;
returnCart() never actually returns null).
…ro, and legacy-property errors

Catalog/CatalogPricing:
- CatalogPricing model: reference real CatalogPricingRule class instead of
  legacy craft\commerce\models\CatalogPricingRule
- Products/Variants: assert narrow Product/Variant type after Elements
  facade's getElementById() call, which loses generic type via __callStatic
- VariantQuery: suppress owner()/primaryOwner() param-type mismatch against
  NestedElementQueryInterface's stricter signature
- CatalogPricing::getCatalogPricesPageInfo(): use getCountForPagination()
  instead of get()->count() to avoid fetching unnecessary rows
- Add @var assertions for Conditions::createCondition() facade calls

Controllers:
- ProductQuery::cleanseQueryCriteria(): drop unnecessary nullsafe on
  request(), which always resolves the bound Request singleton
- PaymentsController: suppress legacy GatewayTrait getIsFrontendEnabled()/
  $handle access via the class_alias chain, drop stray setAttributes()
  2nd arg, initialize $error before conditional assignment, fix
  abort_unless() bool coercion
- EmailsController: use '' instead of null as an array key (PHP already
  coerces null keys to ''), call Email's setTo()/setBcc()/setCc() setters
  instead of nonexistent magic properties
- StoreManagementController: resolve Site::getStore() macro result once
  into a typed local instead of repeating nullable/macro type assertions
- TaxRatesController: call TaxRate's real hasTaxIdValidators()/
  getIsEverywhere()/getTaxZone()/getTaxCategory() methods instead of
  nonexistent magic properties
- UserOrdersController/WebhooksController: suppress Macroable macro and
  class_alias chain false positives
- Order/Carts: drop invalid `false` default passed to Request::cookie()
  (expects array|string|null), compare against null instead
…— 0 errors project-wide

- LineItemStatus::get(): widen return type from ?static to ?self to match
  what LineItemStatuses::getLineItemStatusById() actually returns
- OrderQuery: remove dead is_array() check (containsPurchasables shape is
  already typed as always-array), suppress afterHydrate()'s intentional
  Collection<Order> narrowing of the interface's Collection<ElementInterface>
- Payment/Currencies::getAllCurrencies(): assert Collection<int, Currency>
  since collect() can't infer element type from ISOCurrencies' plain
  IteratorAggregate implementation
- PaymentSource/PaymentSources: suppress legacy craft\commerce\base\Gateway
  class_alias chain false positives (assign.propertyType, method.notFound)
- Purchasables::updateStoreStockCache(): guard with instanceof Purchasable
  before calling Inventory::getInventoryLevelsForPurchasable(), which only
  accepts the concrete inventory-trackable element class
- Stats/RepeatCustomers: use getCountForPagination() instead of
  get()->count() to avoid fetching unnecessary rows

Full project phpstan analyse now reports 0 errors (362 files).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant