Skip to content

6.x - #4124

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

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

Conversation

@lukeholder

@lukeholder lukeholder commented Sep 23, 2025 •

Copy link
Copy Markdown
Member

@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
Move TotalOrders/TotalOrdersByCountry/TotalRevenue (stat+widget pairs) to
Stats\* / Dashboard\Widgets\*, the three chart-bearing widgets. Chart
rendering keeps the existing server-side Chart.js pipeline (data embedded
into the Twig response, same frozen-legacy StatWidgetsAsset/ChartJsAsset JS)
rather than adopting cms-6 core's newer AJAX+D3 pattern.

TotalOrders' plain COUNT(orders.id) scalar simplifies to
createStatQuery()->count(). TotalOrdersByCountry's "other countries"
negation query converts to whereNotIn(). TotalRevenue's dynamic
SUM(total|totalPaid) column interpolation keeps its allow-list guard.

Caught a real settings-field omission by reading the actual Twig template:
TotalOrders' "Show Chart?" and TotalRevenue's "Show Order Count?" boolean
settings aren't referenced anywhere in the widget PHP itself (rendered via
a shared forms.lightswitchField() call in the settings twig) - would have
been silently dropped from settingsForm() otherwise. Used the new Form
system's Lightswitch control. TotalRevenue::defineRules() -> getRules()
returning a plain Laravel validation array.

Repoint 3 unit tests.
Move Orders (the "Recent Orders" widget, the only one of the 11 with no
paired Stat class - queries Order::find() directly) to
Dashboard\Widgets\Orders. Exposes storeId/orderStatuses/limit settings (no
dateRange - this widget has no date-range concept) via the new Form
system, using the Number control for limit.

Repoint all 11 use craft\commerce\widgets\* imports in
src-yii2/Plugin.php::_registerWidgets() to the new namespace.

Confirmed (not fixed): _registerWidgets() only runs on CP requests
(pre-existing getIsCpRequest() gate in Plugin.php::init()), so it never
fires under craft exec:exec - verified the Orders widget class directly
instead since the registration mechanism itself can't be observed live in
this environment.

Ran composer run fix-cs (matching Stage 10n's precedent) - 36 fixable
issues across 24 files, all pure use-line reordering plus a stylistic
`new FormContext()` parens normalization. Caught leftover import disorder
from Stage 10/11's own bulk-sed passes too, not just this stage.

Stage 12 (Dashboard Stats & Widgets) is complete. All 10 stat classes and
all 11 widget classes migrated from src-yii2/{stats,widgets}/ to
Stats\*/Dashboard\Widgets\*, every Yii2 Query/ActiveQuery usage converted
to Laravel's query builder.
…/plans

Cuts over the remaining src-yii2/records/* Yii2 ActiveRecord classes to their
Eloquent counterparts directly (no class_alias shim, since the two APIs are
too different to bridge safely) and deletes the legacy files. Covers
CatalogPricingRule/Queue, Discount, Email, Pdf, SiteStore, Coupon, TaxCategory,
ShippingCategory, StoreSettings, OrderStatus, Customer, Store, and three that
needed brand-new Eloquent classes: OrderNotice, Transfer, and TransferDetail.
Retires three dead pivot records (CatalogPricingRuleUser,
ProductTypeShippingCategory, ProductTypeTaxCategory) whose logic already lives
entirely in DB::table() calls. Fixes a couple of bugs surfaced along the way
(CatalogPricingQueue::getIds() call on an Eloquent model that has no such
method; Payments::refund() throwing SubscriptionException instead of
RefundException).

Also removes all subscription and billing-plan functionality per product
decision: the Subscription element, Plans, the SubscriptionGateway interface,
controllers, CP screens, routes, and related events/forms/models — both the
legacy Yii2 code and the already-fully-migrated CraftCms\Commerce\Subscription\*
Laravel implementation. Database tables are intentionally left in place for a
future data-migration path; only application code is removed.

Includes an unrelated in-progress Types-registry refactor (AdjusterTypes,
DiscountAdjusterTypes, GatewayTypes, PurchasableTypes) that was already
sitting uncommitted in the working tree.
Cleans up src/ (new Laravel-based code) so it no longer routes through
craft\* legacy classes or the Craft::$app-> service locator, per the
project rule that src/ must reference current CraftCms\Cms\* equivalents
directly rather than the yii2-adapter's compatibility layer.

Verified each replacement against the yii2-adapter's own deprecation
pointers and, where relevant, actual method-level API parity before
applying it — several "obvious" swaps (ArrayHelper -> Arr, StringHelper ->
Str, AuthorizationCheckEvent -> ElementAuthorizing) turned out to have
divergent APIs and are intentionally left for a follow-up pass rather than
guessed at.

Covers:
- Site, Address, Entry, PropagationMethod, ElementQuery, NameTrait, and
  i18n\Locale: confirmed class_alias()'d at runtime, so swapping the import
  is risk-free.
- getTemplateMode()/setTemplateMode() -> TemplateMode::get()/::set(), with
  most render calls simplified further by passing TemplateMode directly as
  a parameter instead of mutating global state. Includes a full rewrite of
  Emails::sendEmail()'s 21 scattered save/restore points.
- getUsers(), getSites()/getIsMultiSite(), getFields(), getFormatter()/
  getFormattingLocale(), getEdition()/getUserGroups(), getDrafts(),
  getAddresses(), getPlugins(), getElements(), and the view namespace/JS
  buffer methods (setNamespace, namespaceInputs, startJsBuffer, etc.) ->
  their corresponding CraftCms\Cms\Support\Facades\* equivalents.
- getErrorHandler()->logException()/Craft::error()/Craft::warning() ->
  Illuminate\Support\Facades\Log, per the project's logging docs. Removed
  11 redundant logException() calls in Emails.php that duplicated an
  adjacent Log::error() call.

getMailer() investigated but left alone: composeFromKey()'s replacement
requires a User object, and both call sites send to guest email addresses
with no account. Asset bundle registration (~39 call sites) also left
alone per direction, since it needs a real design change to Craft 6's
declarative asset system, not a swap.
Replaces Craft::$app->getDb() usages with the Illuminate DB facade:
beginTransaction()/commit()/rollBack() become DB:: static calls,
getIsPgsql() becomes DB::connection()->getDriverName() === 'pgsql',
and Carts::purgeIncompleteCarts()'s raw createCommand()->delete()
calls become DB::table()->whereIn()->delete() against materialized
ID lists. Also finishes several isCpRequest()/isSiteRequest()/
session()/Log:: swaps left over from the prior batch (Customers,
OrderHistories, Discounts, HasStoreManagementScreen).
registerJs($js, View::POS_END) becomes HtmlStack::js($js, Position::BodyEnd)
per the legacy adapter's own deprecation notice. registerTranslations()
calls are dropped entirely: the new system bulk-exports the active
locale's whole translation catalog to window.Craft.translations instead
of per-call registration, so these calls became no-ops.
Replaces Craft::$app->getMutex()->acquire()/release() with Laravel's
Cache::lock(), per the legacy adapter's own deprecation notice
(@deprecated 6.0.0. Use \Illuminate\Support\Facades\Cache::lock()
instead). The old acquire($name, $waitTimeout) has no throw/bool split
in Laravel: non-blocking acquisition uses $lock->get(), and blocking
acquisition uses $lock->block($waitTimeout), catching
LockTimeoutException where the old code checked a false return.
Cache::lock() also takes a TTL (auto-expiry) that the old Yii2 mutex
never had; chosen conservatively per call site (30s for DB-only critical
sections, 60s where a gateway HTTP call happens inside the lock).
hashData()/validateData() are thin Crypt::encrypt()/decrypt() wrappers
in the legacy adapter (confirmed by reading the implementation, not
just the docblock pointer, since the @deprecated notice on hashData()
incorrectly points at CraftCms\Cms\Support\Security which has no such
method) so they're swapped directly to Crypt::encrypt()/decrypt(),
preserving the "false on tamper" contract with a DecryptException catch
at each validateData() call site.

getTokens()->createToken()/getTokenRoute() become
app(RouteTokens::class)->createToken()/getTokenRoute() — method
signatures are unchanged, no facade exists for this service.
…tures, getSites, getConfig

getLocale()->getCurrencySymbol()/getOrientation() -> I18N::getLocale(),
per the legacy adapter's deprecation notice pointing at I18N::getLocale().

getStructures()->moveBefore()/moveAfter() -> app(Structures::class),
same method signatures.

getTimeZone() -> date_default_timezone_get(), since Yii2's own
implementation is a thin wrapper around that same builtin.

getSites()->getSiteById()/getEditableSiteIds() -> Sites:: facade.
getEditableSiteIds() now returns a Collection instead of an array, so
the in_array()/array-index call site in ProductsController is updated
to ->contains()/->first().

getConfig()->getGeneral() -> Cms::config(), same mutable GeneralConfig
property (generateTransformsBeforePageLoad) used the same way at every
call site in Emails.php.

getAssetManager()->getPublishedUrl() is left as-is: no Laravel/CMS-6
equivalent found for Yii2 asset publishing, needs follow-up design work.
Cart cookies (forgetCart/setSessionCartNumber) move from
Craft::$app->getResponse()->getCookies()->remove()/add() to Laravel's
Cookie::queue(Cookie::forget()/make()), removing the two TODOs that
were blocking on this.

OrderHistories drops the getResponse()->isSent guard: it defended
against Yii2's queue-after-response trick (continuing script execution
after the HTTP response bytes were already sent), which Craft 6's
RunQueue middleware no longer does — queue jobs now run via a separate
async XHR request instead, so the condition it guarded against can't
happen anymore.

Webhooks::processWebhook() had a latent type bug: its own return type
used yii\web\Response (via a stale import) while
GatewayInterface::processWebHook() already committed to
Illuminate\Http\Response, so the success path and the
getResponse()-based exception path returned incompatible types. Fixed
by typing the whole method to Illuminate\Http\Response and building
the exception-path response via `new Response('', $statusCode)`
(status derived from HttpException::$statusCode, matching Yii2's
setStatusCodeByException()). This also required fixing the legacy
src-yii2/services/Webhooks.php delegating wrapper, which still declared
the old yii\web\Response return type and would have thrown a TypeError
at runtime after the new class's return type changed, and
WebhooksController.php's own return type (Responsable, which
Illuminate\Http\Response never implemented).

DownloadsController's sendContentAsFile() becomes a plain
response($content, 200, [...]) with manually-set Content-Type/
Content-Disposition headers (HTTP Range support is dropped since it
wasn't load-bearing for PDF checkout downloads).

Not changed: Payments.php's getResponse()->redirect()+end() and
handleRedirect()'s echo+end() (see follow-up notes) — Craft::$app->
getResponse() and ::end() carry no @deprecated notice anywhere in the
yii2-adapter, unlike every other method fixed this session, so forcing
a redesign here isn't a confirmed migration requirement and the
existing controllers already have their own correct return-based
redirect handling for the paths that matter.
… sites)

composeFromKey() requires a real Craft User in the new
SystemMessages::mailable() convenience method, which doesn't fit these
two guest-checkout call sites (cart recovery, PDF download links sent
to an email address with no CMS user account). Verified the underlying
SystemMessageMailable class needs no User at all — that requirement is
entirely inside the convenience wrapper — so both call sites construct
SystemMessageMailable directly and send it via Mail::to($email)->send().

Also confirmed these message keys ('commerce_pdf_download',
'commerce_cart_recovery') still resolve correctly: they're registered
through the legacy Yii2 SystemMessages::EVENT_REGISTER_MESSAGES event
in src-yii2/Plugin.php, and CraftCms\Yii2Adapter\SystemMessage\
LegacySystemMessages bridges that event into the new system
automatically, so no registration changes were needed.

Laravel's Mailer::send() throws on transport failure and returns null
(rather than false) when a listener cancels the send, instead of Yii2's
bool-returning Message::send() — wrapped in try/catch and a null check
to preserve the original "show a flash error and let the user retry"
behavior on failure.

Not addressed: Emails.php's two getMailer() call sites (lines 301, 667).
Unlike the composeFromKey() cases, this is Commerce's own custom
transactional order-email pipeline: it builds a raw legacy
craft\mail\Message object by hand (from/reply-to/subject/HTML+text
body/attachments) across roughly 400 lines and sends it directly via
the mailer component. Migrating this properly means redesigning it
around a real Laravel Mailable, not a like-for-like call swap, and
deserves its own dedicated pass.
…ail pipeline)

Replaces Craft::$app->getMailer()->send($newEmail) with the exact
mechanism Craft 6's own craft\mail\Mailer::sendMessage() already uses
internally to dispatch a raw craft\mail\Message through Laravel's
configured mail transport:

    app('mail.manager')->mailer()->getSymfonyTransport()->send($newEmail->getSymfonyEmail());

Found by reading the legacy Mailer's actual implementation (rather than
just its class-level @deprecated pointer, which — like hashData()
earlier this session — just says "use Laravel mailers/drivers" without
naming a concrete replacement). craft\mail\Message itself carries no
deprecation notice and still wraps a real Symfony Email under the
hood via getSymfonyEmail(), so the ~360 lines that build up $newEmail
(from/to/cc/bcc/replyTo/subject/HTML+text bodies/PDF attachment, all
via sandboxed Twig rendering) needed no changes — only how the fully
built message actually gets handed to a transport.

This also keeps MailEvent::$craftEmail (the public
beforeSendEmail/afterSendEmail extension point plugins listen to)
typed as craft\mail\Message, unchanged, since nothing about that
contract needed to move.

getSymfonyTransport()->send() throws (Symfony's
TransportExceptionInterface extends \RuntimeException extends
\Exception) instead of returning false on failure, so the explicit
`if (!...->send())` failure branch is removed — Emails::sendEmail()
already wraps this whole block in a catch (\Exception $e) with its own
(more detailed) error message and identical cleanup, so a thrown
transport failure is caught there instead.

Also simplified $newEmail's construction from
Craft::createObject(['class' => $mailer->messageClass, 'mailer' => $mailer])
to `new Message()` directly, since sending no longer goes through
craft\mail\Mailer at all and the method already hard-depends on
craft\mail\Message's own API via its calls throughout.

This closes out the getMailer() sweep — zero Craft::$app->getMailer()
call sites remain in src/.
…s equivalents

craft\helpers\ArrayHelper is deprecated in favor of CraftCms\Cms\Support\Arr
(which extends Illuminate\Support\Arr), but the two aren't a drop-in swap —
verified each of the 14 distinct methods actually used (60 call sites across
21 files) against real signatures before touching call sites, since several
diverge in shape from their Yii2 counterpart:

- getColumn() -> Arr::pluck(): safe everywhere it's used here since every
  call site either passes keepKeys=false explicitly or consumes the result
  in a way that doesn't depend on key preservation (implode(), whereIn()).
- merge(), toArray(), contains() -> Arr::merge()/toArray()/contains(): true
  1:1 replacements, CraftCms\Cms\Support\Arr reimplements these with
  identical semantics to the legacy versions.
- firstWhere() -> Arr::first() with a full predicate closure (Laravel's Arr
  has no firstWhere() — that's Collection-only). Existence-only checks
  (`(bool)firstWhere(...)` or `!firstWhere(...)`) became Arr::contains()
  instead, since that's a closer match to the actual intent.
- where() -> Arr::where() with a full boolean predicate (different shape:
  Yii2 takes key/value/strict args, Laravel takes one callback).
- index() -> Arr::keyBy() (single-key form only; no grouping used here).
- map() -> Arr::mapWithKeys() with a callback returning [key => value]
  (Laravel's Arr::map() only transforms values in place, not keys, so it's
  not the right target) — or a Collection's own mapWithKeys() when the
  input was already a Collection instead of a plain array.
- firstKey() -> array_key_first(), isIn() -> in_array(): the legacy
  ArrayHelper itself already points at these.
- removeValue() -> array_filter() (mutation target reassigned).
- multisort() -> usort() with a descending comparator (single sort key
  only, matching the one real usage).
- prependOrAppend() -> array_unshift() (single call site, prepend only).
- isAssociative() -> inlined native check, since its default mode (ALL
  keys must be strings) doesn't match Arr::isAssoc()'s semantics (true for
  ANY non-sequential key) — using the wrong one would have flipped behavior
  for line-item option arrays with mixed key types.
…ms equivalents

craft\helpers\StringHelper is deprecated in favor of
CraftCms\Cms\Support\Str (extends Illuminate\Support\Str). Verified each
of the 4 methods actually used (23 call sites across 16 files) against
the legacy adapter's own delegation code before swapping:

- randomString($length, $extendedChars) -> Str::random($length,
  $extendedChars): the legacy method's own body is `return
  Str::random($length, $extendedChars);`, and CraftCms\Cms\Support\Str
  overrides random() with the same two-arg signature, so this is a
  confirmed 1:1 swap, not a guess.
- toTitleCase($str) -> Str::title($str): same confirmation — the legacy
  method's body delegates directly to Str::title().
- UUID() -> (string)Str::uuid(): Laravel's Str::uuid() returns a
  Ramsey\Uuid\UuidInterface object, not a plain string like the old
  method, so every call site needed an explicit cast to keep assigning
  into string-typed properties/array values.
- split($str, $delimiter) -> inlined preg_split('/\s*' . delimiter .
  '\s*/', $str, -1, PREG_SPLIT_NO_EMPTY) at each of the 3 call sites:
  the legacy method itself was never delegated to the new Str class (no
  Str::split() exists), it's just a raw preg_split with no wrapper, so
  there's nothing to route through beyond replicating that expression
  directly.
…n src/

UrlHelper -> CraftCms\Cms\Support\Url (url, cpUrl, siteUrl, actionUrl,
urlWithParams): all confirmed to exist on the new class with identical
signatures, a pure rename.

MoneyHelper -> CraftCms\Cms\Support\Money (toMoney, toDecimal, toString):
verified against the legacy adapter's own implementation, which is a
straight pass-through to the new class for every method actually used
here, a pure rename.

DateTimeHelper: toDateTime() is inherited unchanged from
CraftCms\Cms\Support\DateTimeHelper (no override), so importing the new
class directly is a pure rename. secondsToInterval() and
currentUTCDateTime() are different — they exist only on the legacy
bridge class, not the new one — so their two call sites in Carts.php
were inlined directly per the legacy source's own one-line bodies:
new DateInterval("PT{$seconds}S") and now('UTC').

Also fixed a real bug this surfaced: my first pass at the DateInterval
inlining used a bare `new DateInterval(...)` with no import, which
PHPStan caught resolving to the wrong class (CraftCms\Commerce\Order\
DateInterval) since — unlike functions — unqualified class references
in PHP do NOT fall back to the global namespace. Added `use DateInterval;`
to fix it.

Not touched: FileHelper::isWritable() (the legacy version does a real
write-attempt test via fopen(); Laravel's version is just is_writable(),
a strictly weaker check — swapping would be a behavior downgrade, not a
neutral rename) and craft\helpers\Queue::push() (wraps legacy JobInterface
jobs in a LegacyJobWrapper before dispatching; its two call sites pass
SendEmail/ResaveProductVariants jobs that haven't been migrated to
Laravel ShouldQueue jobs yet, so swapping to the bare Queue facade would
break dispatching until those job classes are migrated too).
Both classes share the "Json" short name, so this is purely an import
swap for the 14 files using only encode()/decode() — both are direct
pass-throughs (encode) or a verified same-contract reimplementation
(decode: same InvalidArgumentException-on-bad-JSON / null-on-empty
behavior, confirmed against the legacy adapter's inherited Yii2 base
implementation) on the new class, so no call-site text changes needed.

OrdersController.php needed real changes: its ~25 fully-qualified
\craft\helpers\Json::encode()/decodeIfJson() calls became
\CraftCms\Cms\Support\Json::, and its 2 htmlEncode() calls (a method
that only exists on the legacy adapter, inherited from Yii2's base Json
class with no delegation to the new class) were inlined as
Json::encode($value, JSON_UNESCAPED_UNICODE | JSON_HEX_QUOT |
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) — the exact flag set
Yii2's own htmlEncode() uses internally.
Verified all 9 methods actually used (encode, tag, a, beginTag, endTag,
hiddenInput, input, hiddenLabel, namespaceInputName/namespaceInputs)
before swapping. The legacy class extends \yii\helpers\Html directly
with no override for encode()/input(), and the new class's
__callStatic() falls back to the exact same Yii2 base class for any
method it doesn't reimplement — so encode()/input() are byte-for-byte
identical either way, and the 7 explicitly-reimplemented methods
already have established, working call sites elsewhere in this
codebase from earlier sessions.

Since both classes share the "Html" short name, most files only needed
their `use` statement swapped. Six files already had the new class
imported as `NewHtml` (from earlier partial migration work) alongside
the old bare `Html` import — those needed the bare Html:: calls renamed
to NewHtml:: and the old import dropped, done via a negative-lookbehind
regex to avoid double-prefixing the already-correct NewHtml:: calls
(a plain find-replace would have produced NewNewHtml::). Three files
used the fully-qualified \craft\helpers\Html:: form with no import;
those got a proper `use` statement added and calls shortened to bare
Html::.
…mfony equivalents

Category A3: swaps each Yii2 framework exception (not craft\* namespace,
so lower priority per CLAUDE.md, but still in scope) for its native PHP
or Symfony equivalent across ~24 files. The mapping preserves the
original author's own already-made distinction between "bad argument"
and "bad state" at each throw site rather than re-judging every one:

- yii\base\InvalidConfigException -> \RuntimeException (no perfect
  native equivalent — Yii2's own class just extends \Exception
  generically for "something about current state/config is wrong").
- yii\base\InvalidArgumentException -> native \InvalidArgumentException.
  Same name, but a real behavioral fix: Yii2's version actually extends
  \BadMethodCallException, not the native SPL class, so a
  catch (\InvalidArgumentException) elsewhere would never have caught
  it.
- yii\base\Exception -> native \Exception (Yii2's own class already
  extends it directly with no behavior difference).
- yii\base\InvalidCallException -> native \BadMethodCallException.
- yii\base\ErrorException -> native \ErrorException (Yii2's own class
  extends it directly; constructor signature is a superset).
- yii\web\HttpException / BadRequestHttpException (Webhooks.php) ->
  Symfony\Component\HttpKernel\Exception equivalents — same ecosystem
  as the Response class already used in that file, and what Laravel's
  own abort() throws under the hood. Required changing
  $exception->statusCode to $exception->getStatusCode(), since Symfony
  exposes it as a method, not a public property like Yii2's version.

A real bug surfaced mid-sweep: unqualified native class references
(new DateInterval(...), and almost RuntimeException too) do NOT fall
back to the global namespace the way unqualified function calls do —
PHPStan caught one resolving to CraftCms\Commerce\Order\DateInterval
instead of the global class. Fixed by always using an explicit `use`
import or a `\`-prefixed fully-qualified name at every throw/catch site
introduced by this sweep, never a bare global class name.

Left alone, with reasons (documented in updated-laravel-migration-private.md):
yii\base\Event (both usages deliberately fire against a legacy class
name string for third-party Event::on() listener backward
compatibility), yii\mail\MailEvent (correctly paired with the legacy
Mailer::EVENT_BEFORE_PREP hook, which still constructs exactly this
type), yii\validators\Validator (sits inside a method explicitly marked
"not yet wired up to a real validator, pending the Ruleset system" —
dead code with no confirmed future type yet), yii\base\ExitException
(tied to the already-flagged, not-yet-redesigned getResponse()/end()
redirect flow in Payments.php), and yii\db\Expression (correctly paired
with the still-legacy craft\db\Query object it's passed into — Category
B2 scope, not this one).
Swaps the legacy service-locator indirection for direct container
resolution across ~557 call sites in 98 files. HasServices
(src/Plugin/Concerns/HasServices.php) only exists to keep src-yii2/
code working against the legacy craft\commerce\services\X wrapper
classes; src/ code has no reason to route through it. The getter ->
new-class mapping was derived from each legacy wrapper's own
app(X::class) delegation target, not guessed.

Left alone: getSettings() (framework-level Plugin/HasSettings
accessor, not a service getter), the single getTransfers() site
(Transfers hasn't moved to src/ yet), and ~86 event-firing chains
(hasEventHandlers()/trigger()) plus two PaymentCurrencies::
convertCurrency() sites that deliberately need the legacy wrapper's
inherited yii\base\Component event methods / not-yet-ported method -
a first pass swapped these too and PHPStan's existing
per-line ignore comments masked the breakage, so these were reverted
back to Plugin::getInstance()->getX() explicitly.

Also fixed a real bug this surfaced: Store/Models/Store.php's
getSettings(): StoreSettings return type bare-resolves to the
same-namespace Models\StoreSettings; adding a use import for the
StoreSettings *service* (same short name) for an unrelated call
shadowed that resolution and pointed the return type at the wrong
class. Fixed by using the FQCN inline instead of importing.

Verified with a full PHPStan diff (--error-format=raw) against a
pre-change stash baseline, matched by (file, message) to stay immune
to line-number drift from added imports - remaining differences are
pre-existing type-hint gaps now attributed to the new class name
instead of the legacy wrapper's, not regressions. Also ran php -l,
phpstan, and fix-cs/check-cs across all touched files.
…FIG_*_KEY constants

Swaps craft\commerce\services\X imports for their CraftCms\Commerce\*
equivalents in the few src/ files that only reference the legacy
class for a ProjectConfig CONFIG_*_KEY constant (or, for Coupons,
DEFAULT_COUPON_FORMAT) - confirmed each new class already defines the
identical constant. Also simplifies a few app(\Fully\Qualified\X::class)
calls left over from the Category B1 pass back to bare app(X::class)
now that the correct class is imported directly.

craft\commerce\services\Transfers (2 sites) is deliberately left alone
- Transfers hasn't been migrated to src/ yet, so no new-class target
exists. src/Plugin/Concerns/HasServices.php is also left alone - its
imports of the legacy service classes are intentional, not stale.
…ass_alias

Swaps craft\commerce\* imports for their CraftCms\Commerce\* equivalents
across 81 files, restricted to the ~52 classes confirmed runtime-identical
via a literal class_alias() call in src-yii2/ (e.g. craft\commerce\elements\Order
-> CraftCms\Commerce\Order\Elements\Order, craft\commerce\models\Store ->
CraftCms\Commerce\Store\Models\Store) - not guessed from deprecation
notices. Covers the Order element, base Purchasable/PurchasableInterface,
and most Models/enums/exceptions still referenced by their legacy name.

Left alone: craft\commerce\Plugin (not aliased - src-yii2's Plugin
extends the new one, doesn't alias it), craft\commerce\services\*
(legacy wrapper classes, some still legitimately needed - see the B1
commit), craft\commerce\web\assets\* (asset-bundle registration,
already deferred), and everything else with no class_alias entry.

src/Order/Adjuster/Discount.php's `use craft\commerce\adjusters\Discount
as LegacyDiscount;` is deliberately excluded even though it IS aliased -
LegacyDiscount::class is fired as an Event::trigger() name-string for
third-party backward compatibility, and ::class resolves to the name as
written, not the alias target, so renaming it would silently change
which event name gets fired.

Two real bugs surfaced and fixed along the way:
- Several files (Inventory.php, Order.php) already had a *second*,
  differently-aliased import for the same target class (e.g. `Purchasable
  as NewPurchasable`) for a `Purchasable|NewPurchasable` union type or
  `instanceof` check that predates this pass. Adding a second bare import
  for the same class is a duplicate-type fatal (caught by php -l for type
  declarations) or dead code needing simplification (for instanceof/union
  expressions, which php -l does NOT catch - found via a full-codebase
  sweep for any FQCN imported under two different names).
- StoreTrait.php and three model classes declared `getStore(): \craft\commerce\models\Store`
  inline (not via a `use` import), which PHPStan started flagging as a
  return-type mismatch once the *callee* (Stores::getStoreById()) was
  migrated to the new class name - same runtime class via class_alias,
  but PHPStan treats aliased classes as nominally different types.
  Updated all four to the new class name directly.

craft\commerce\models\TransferDetail is reverted back to the legacy name
in TransfersController.php specifically - it's passed into
craft\commerce\elements\Transfer::addDetail(), which is entirely
unmigrated legacy code still typed against the old name.

Verified with a full PHPStan diff (--error-format=raw) matched by
(file, message) to stay immune to line-number drift - remaining
differences are pre-existing type-hint gaps in still-legacy method
signatures (e.g. craft\commerce\base\Gateway), not regressions. Also
ran php -l and check-cs/fix-cs across all touched files.
- src-yii2/templates/index.twig: update stale editableProductTypes()
  call to viewableProductTypes() (renamed pre-migration, deprecated
  method dropped from the new ProductTypes service)
- StoresController/StoreManagementController: cast request storeId
  input to int before passing to strictly-typed Stores::getStoreById()
- Store/Zone/Email/OrderAdjustment models: add validationData()
  overrides so private getter-backed attributes (name, currency,
  condition, to, sourceSnapshot) are actually visible to validate(),
  which was silently failing "required" rules and blocking saves
- InventoryLocations/OrderStatuses/TaxCategories/ShippingCategories/
  CatalogPricingRules/Customers/Stores: raw DB::table()->insert()
  calls into commerce_* pivot tables were missing dateCreated/
  dateUpdated, which are NOT NULL with no DB default
- Plugin.php: override cpNavIconPath() so the Commerce CP nav item
  gets an icon; the base implementation returns a filesystem path,
  but the CP's craft-icon component only resolves published icon
  names, so it never rendered
…rollers

Same root cause as the reactive fixes already committed: Request::input()
always returns raw (string) values, and strict_types=1 means PHP no longer
silently coerces those into int/float/?int/?float-typed method parameters
or model/DTO properties. Audited every controller under src/Http/Controllers
and cast at each call site where a raw request value crossed into a
strictly-typed target — IDs passed to getXById()-style service methods,
model property assignments (storeId, methodId, etc.), and a couple of
Localization::normalizeNumber() results assigned to float properties.

No behavior changes beyond fixing the type mismatches; null-handling was
preserved wherever a field was allowed to be absent/null.
… test harness

Plugin.php bootstrap migration:
- Group 3: migrated remaining Craft event listeners (SiteSaved/SiteDeleted,
  ElementSaved, DefineDeletionBlockers, ElementAuthorizing) to src/Plugin.php,
  removed the now-dead legacy delegator methods they fed.
- Group 4: dynamic per-product-type permissions in Plugin::getPermissions().
- Group 5: CP nav ported to the NavItem builder in Plugin::getCpNavItem().
- Group 6: added craft:resave:products/variants/orders/carts console commands.
- Group 7: StoreBehavior/CustomerBehavior/CustomerAddressBehavior replaced
  with Macroable macros on Site/User/Address (the legacy Yii2 behaviors were
  silently non-functional since Site/User/Address no longer extend
  yii\base\Component and attachBehavior() doesn't exist on them).
- Group 8: GQL schema components/eager-loadable fields, element exporters,
  garbage collection, Twig extension, and craft.commerce Twig variables
  migrated to their new-system registration points.

Category B2 part 3: checked all ~44 remaining stale craft\commerce\* imports
in src/ individually; fixed 3 real issues (a dead PaymentIntents reference,
stale StoreBehavior docblocks, and a TransferDetail class-alias type
mismatch), confirmed the rest are legitimate references to not-yet-migrated
legacy code.

Also includes the Pest/Orchestra Testbench test harness (tests-yii2/ rename,
Codeception removal, tests/Feature and tests/Unit scaffolding) and a handful
of unrelated fixes (Locale::switchAppLanguage formatting locale, Stores.php
schema-version guard) staged from earlier work.

See updated-laravel-migration-private.md for full verification detail.
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).
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.

2 participants