Skip to content

Spec 0021 — State machines#2501

Draft
glennjacobs wants to merge 17 commits into
2.xfrom
spec-0021-state-machines
Draft

Spec 0021 — State machines#2501
glennjacobs wants to merge 17 commits into
2.xfrom
spec-0021-state-machines

Conversation

@glennjacobs
Copy link
Copy Markdown
Contributor

@glennjacobs glennjacobs commented May 28, 2026

Summary

Implements spec 0021spatie/laravel-model-states v2 across core:

  • Channel — Active ⇄ Inactive
  • Product / Collection — Draft ↔ Published ↔ Archived
  • Order — three coordinated machines: PaymentState (5 states), FulfilmentState (7), OrderState (11) with OnHold / Cancelled as manual overrides.

OrderObserver rewritten to dispatch OrderStatusUpdated on payment/fulfilment changes, log activity, and resume computation when leaving a manual-override state. SendOrderStatusNotifications listener fan-outs to per-state notifications.

Retires SoftDeletes on Product, ProductVariant, Channel, Collection — the new Archived / Inactive states replace the soft-delete-as-archive pattern. Cart and Staff keep soft-deletes (different concern). Baseline migrations edited in place per the v2 pre-release rule (spec 0019 precedent).

Translations added across all 16 locales (states.php).

Extension seam

The abstract state bases (PaymentState, FulfilmentState, OrderState) read their catalogue, transitions and defaults from the bound OrderStateConfig at construction time — the contract is the single seam for everything order-state-related. Consumers extend DefaultOrderStateConfig, add states + transitions + notifications + resolver overrides, and bind the subclass in their service provider:

class MyOrderStateConfig extends DefaultOrderStateConfig
{
    public function paymentStates(): array
    {
        return [...parent::paymentStates(), PartiallyCaptured::class];
    }

    public function paymentTransitions(): array
    {
        return [
            ...parent::paymentTransitions(),
            Captured::class => [PartiallyCaptured::class, ...parent::paymentTransitions()[Captured::class]],
            PartiallyCaptured::class => [Captured::class],
        ];
    }

    public function resolveOrderState(PaymentState $payment, FulfilmentState $fulfilment): string
    {
        // map the new payment state, or delegate
        return parent::resolveOrderState($payment, $fulfilment);
    }
}

OrderStateConfigExtensionTest proves this works end-to-end — a custom config adds PartiallyCaptured, transitions into it, and round-trips through the cast without any core change.

Bind in register() (not boot()): Spatie's State base caches the resolved state mapping per class for the process lifetime, so the catalogue must be in place before any model uses the cast. Under Octane this cache survives between requests.

Notifications

State-status notifications are resolved through OrderStateConfig::notificationsFor(OrderState). DefaultOrderStateConfig reads from lunar.orders.notifications.{name}, so the simple "drop a class in config" case still works:

// config/orders.php
'notifications' => [
    'shipped' => [App\Notifications\OrderShipped::class],
    'complete' => [App\Notifications\OrderComplete::class],
],

Consumers needing programmatic resolution (database, feature flags, per-customer-group) override notificationsFor() in their subclass. Order gains Illuminate\Notifications\Notifiable so $order->notify() works directly.

Breaking changes

The public-contract changes will land in the upgrade package's Rector rules as a follow-up:

  • Order::$status removed; replaced by payment_status, fulfilment_status, order_status (state-cast).
  • Order::getStatusLabelAttribute() removed; use $order->order_status->label().
  • SoftDeletes removed from Product, ProductVariant, Channel, CollectionwithTrashed() / restore() callers must move to the relevant state.
  • lunar.orders.statuses config block removed; replaced by lunar.orders.notifications (different shape, scoped to notifications).
  • MarkOrderAsShipped action no longer takes UpdatesOrderStatus as a collaborator — transitions fulfilment_status directly.
  • UpdateOrderStatus action signature unchanged but now mutates order_status and validates against the registered OrderState catalogue rather than lunar.orders.statuses config.

Tooling

Adding spatie/laravel-model-states exposed a matrix gap: no version supports both PHP 8.3 and Laravel 13 (2.12.2+ added L13 but bumped to PHP ^8.4; 2.11.x supports PHP 8.3 but caps at L12). Aligned with upstream by bumping the minimum to PHP 8.4 across all 9 package composer.json files; PHP 8.5 added to the matrix.

  • tests.yml — matrix is now ["8.4", "8.5"] × ["12.*", "13.*"]
  • static-analysis.yml — same shape
  • fix-code-style.yml, document-facades.yml — bumped single-version 8.3 → 8.4

User-facing impact: anyone on PHP 8.3 cannot install v2 after this lands.

Test plan

  • vendor/bin/pint --dirty --format agent — pass
  • vendor/bin/phpstan analyse --no-progress — no errors
  • vendor/bin/pest --testsuite=core — 689 passed, 1 skipped (incl. 78 new tests under tests/core/Unit/States/ and tests/core/Unit/Listeners/)
  • vendor/bin/pest --testsuite=admin — 189 passed
  • vendor/bin/pest --testsuite=filament — 40 passed
  • vendor/bin/pest --testsuite=stripe — 40 passed
  • vendor/bin/pest --testsuite=shipping — 98 passed
  • vendor/bin/pest --testsuite=search — 4 passed
  • vendor/bin/pest --testsuite=upgrade — 39 passed
  • Custom-config extension proven by OrderStateConfigExtensionTest
  • Notification fan-out proven by SendOrderStatusNotificationsTest
  • Smoke-test the order resource end-to-end in the host app at https://lunar-v2.test
  • Confirm payment/fulfilment selects only offer legal next states (Filament polish for stage 2)

🤖 Generated with Claude Code

Adopts spatie/laravel-model-states v2 across core:

- Channel: Active ⇄ Inactive
- Product / Collection: Draft ↔ Published ↔ Archived
- Order: three coordinated machines — PaymentState (5), FulfilmentState (7),
  OrderState (11) with OnHold / Cancelled as manual overrides

Adds Contracts\OrderStateConfig + DefaultOrderStateConfig (bound in
LunarServiceProvider) which drives Order::computeOrderStatus() via a
category-pair matrix and override map. OrderObserver dispatches
OrderStatusUpdated on payment/fulfilment changes and resumes computation
when leaving a manual-override state. SendOrderStatusNotifications
listener wired in bootingPackage().

Retires SoftDeletes on Product, ProductVariant, Channel, Collection — the
new Archived state replaces the soft-delete-as-archive pattern. Cart and
Staff keep soft-deletes (different concern). Baseline migrations edited
in place per the v2 pre-release rule (spec 0019 precedent).

Translations added across all 16 locales (states.php).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
glennjacobs and others added 7 commits May 28, 2026 14:51
spatie/laravel-model-states 2.12.2+ added Laravel 13 support but bumped
its PHP requirement to ^8.4. The 2.11.x line still works on PHP 8.3 but
doesn't list Laravel 13 in illuminate/support, so there is no version
that bridges PHP 8.3 + Laravel 13 — which the CI matrix exercises.

Aligning with upstream: bump every package's PHP requirement to ^8.4
and drop PHP 8.3 from the test matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the tests.yml matrix change — static-analysis.yml's PHP 8.3 + L13
cell hits the same spatie/laravel-model-states gap. fix-code-style.yml and
document-facades.yml are single-version; bumped to 8.4 to stay consistent
with the new minimum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously PaymentState::config(), FulfilmentState::config() and
OrderState::config() hardcoded their registered states, transitions
and defaults. The OrderStateConfig contract returned the same data
but nothing read it — leaving consumers with no clean way to add a
new payment / fulfilment / order state without subclassing the
abstract base AND swapping the Order model.

The three abstract bases now read their state catalogue, transitions
and default from the bound OrderStateConfig at construction time.
Consumers extend DefaultOrderStateConfig, add states + transitions,
and bind the subclass in their service provider — one seam.

- Add defaultPaymentState() / defaultFulfilmentState() /
  defaultOrderState() to the contract.
- Document the binding-at-register() requirement and the Octane
  cache caveat in the contract docblock and the spec.
- Add OrderStateConfigExtensionTest proving the seam works end-to-end:
  bind a custom config that adds PartiallyCaptured, transition into it,
  round-trip through the cast.

Sub-states aren't modelled hierarchically — StateCategory continues
to serve as the grouping mechanism, and new states classify into an
existing category via category().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the legacy lunar.orders.statuses config was removed, the
SendOrderStatusNotifications listener was left reading a path that
no longer existed — notifications were wired but unreachable.

Adds notificationsFor(OrderState $state): array to the contract.
DefaultOrderStateConfig reads lunar.orders.notifications.{name}, so
the simple "drop a class in config" path still works. The listener
now injects OrderStateConfig and resolves through it, keeping the
container as the single seam: consumers override notificationsFor()
to source notifications from anywhere (database, feature flags,
per-customer-group), or keep the config-driven default.

Order gains Illuminate\Notifications\Notifiable so $order->notify()
works directly. Adds a SendOrderStatusNotificationsTest covering
both the configured-and-fires and nothing-configured paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the "PRs run a slimmed matrix; push and the schedule run the
full one" pattern that existed before PHP 8.5 was added. PRs now run
PHP 8.4 against L12 + L13; the nightly sweep and pushes to 2.x run the
full PHP 8.4 + 8.5 matrix.

Brings PR job count back from 32 to 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move spec into specs/completed/
- Flip status: implemented → completed
- Update specs/README.md index
- Remove from TODO.md Outstanding, add to Done

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@glennjacobs glennjacobs marked this pull request as ready for review May 28, 2026 14:39
OrderResource::getNavigationBadge() was querying the dropped `status`
column with the legacy `payment-received` value (which was passed as
a string rather than an array, so even before the rename it would
have errored on whereIn).

- Query `order_status` instead of `status`.
- Default value changed to `['in-process']` — the new equivalent of
  "payment captured, not yet shipped".
- Update the config comment to point at Lunar\Core\States\Order\Order
  as the source of truth for valid values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@glennjacobs glennjacobs marked this pull request as draft May 28, 2026 14:43
glennjacobs and others added 8 commits May 28, 2026 15:49
Catch-up to the OrderResource navigation badge fix — same pattern,
different consumers:

- ListOrders tabs: build from OrderStateConfig::orderStates() and query
  order_status instead of reading the removed lunar.orders.statuses
  config.
- DisplaysOrderSummary infolist: TextEntry::make('order_status').
- OrderTable: column + select filter retarget to order_status.
- OrderStatus support helper: resolve label via the State catalogue
  (State::label()) and source color from a new
  lunar-filament.order.status_colors config block. The legacy
  lunar.orders.statuses.* lookup is gone.
- OrderIndexer: index payment_status / fulfilment_status / order_status
  instead of the dropped status column; drop __soft_deleted from the
  filterable fields list (Order doesn't soft-delete).
- ProductGlobalSearch: render $product->status->label() rather than
  the State object's __toString().
- Transaction PHPDoc: drop stale ?Carbon $deleted_at (Transaction has
  never soft-deleted in v2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The enum is only ever used by PaymentState::category() and
FulfilmentState::category() to bucket states for the order-status
resolver matrix. Its previous name suggested it was a general-purpose
state classification for any machine — it isn't. Scoping the name to
the order machines clarifies intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The product edit screen still shipped ForceDeleteAction + RestoreAction
in its header — both no-ops now that Product has no SoftDeletes trait —
and the Update Status radio only listed Draft / Published, hiding the
new Archived state behind nothing.

- Drop ForceDeleteAction and RestoreAction from the header.
- Drive the status Radio options from ProductState::transitionableStates(),
  so the selector exposes only legal next states (preventing illegal
  Draft → Archived direct hops). The current state is always included
  so the radio renders with a sensible default.
- Add lang keys for the `archived` option label + description across all
  16 locales (English written, others mirror as placeholder).
- Add an Archived tab to the products list page so archived products
  are discoverable, with matching lang key.

The default Filament DeleteAction stays for genuine hard deletes (test
data, cleanup) — it now does what its label says rather than tombstoning.

Note: EditCollection / EditChannel still use plain DeleteAction. Their
state machines aren't surfaced in the edit UI yet — that's deeper UX work
for the existing Stage 2 admin retarget tracked in the spec's open list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Product status had transitions restricted to:
  Draft → Published,  Published ↔ Draft,  Published ↔ Archived,
  Archived → Draft

which blocked Draft → Archived (admin can't archive a never-published
product directly) and Archived → Published (admin can't republish a
shelved product without an interim Draft hop). Same constraints on
Collection.

Unlike order payment / fulfilment — where transitions encode genuine
domain invariants — product / collection lifecycle is just a visibility
flag with no rules against any pairing. The state machine still earns
its keep here for type safety, the cast, labels and consistency with
the order machines, but transition gating isn't pulling weight.

Both machines now allow every (state, state) pair except identity.
Transition tests updated to reflect the new mesh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With SoftDeletes retired, Filament's stock Delete confirmation ("Are
you sure you would like to do this?") understates the consequence —
deletion is now permanent, and an admin who deletes a product that
appears on past orders loses the ability to drill into those orders'
product context (the OrderLine still has snapshot data, but the
Product row itself is gone).

Adopting the Shopify-style convention: products with order history
can be archived but not deleted. Same for channels.

- Product::hasOrderHistory(): bool — true if any of the product's
  variants appear on any OrderLine.purchasable_id (filtered to the
  product_variant morph alias).
- Channel::hasOrderHistory(): bool — true if any Order references
  this channel_id.
- EditProduct's DeleteAction: disabled when hasOrderHistory(), with
  a tooltip pointing the user to Archive. When enabled, the modal
  description now spells out that deletion is permanent and suggests
  archiving as the safer move.
- EditChannel's DeleteAction: same treatment, but pointing at the
  Inactive state (channels don't have an Archived state).
- Lang keys for the three new strings (confirm / blocked /
  disabled_tooltip) added across all 16 locales for product.php and
  channel.php.
- HasOrderHistoryTest covers the four cases that matter:
  no-variants-no-orders, ordered-variant, other-product's-orders,
  channel-with-orders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for the product edit page:

1. The "Currently in draft status, this product is unavailable…"
   callout fired for any non-published state, including Archived,
   showing the wrong copy. Split the shout into status-specific
   variants:
   - draft: info callout with the draft copy
   - archived: warning callout explaining the product is hidden but
     kept on record, with a hint on how to revive it
   - published: existing availability warnings (no status callout)
   ProductForm gains isDraft() / isArchived() helpers alongside the
   existing isPublished().

2. The Update Status button gave no indication of the current state
   without opening the modal. Label is now driven by a new
   `label_with_state` lang key ("Status: Published") and the button
   colour reflects the state (success / warning / gray).

Lang key churn:
- packages/filament/resources/lang: rename status.unpublished →
  status.draft and add status.archived (16 locales).
- packages/admin/resources/lang: add product.actions.edit_status.label_with_state
  (16 locales). Drop the dead `status.unpublished` block — it was
  unused after this change (the production code reads from the
  lunar-filament:: namespace, not lunarpanel::).
- Fix tests/admin/.../EditProductTest.php — one assertion was reading
  `lunarpanel::product.status.unpublished.content` when production
  renders `lunar-filament::product.status.draft.content`. They had
  matched by accident; retarget the assertion to the real key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The order summary panel only rendered order_status, hiding the two
underlying states that drive it. Per spec §K ("Three badges (payment /
fulfilment / order), with order styled as the 'computed' one"):

- DisplaysOrderSummary gains getOrderSummaryPaymentStatusEntry()
  and getOrderSummaryFulfilmentStatusEntry() — both badge entries
  with state-appropriate colour mapping (captured → success,
  failed → danger, backordered → warning, etc.).
- getOrderSummaryStatusEntry() now carries a helperText that flips
  between "Computed from payment + fulfilment" and "Manual override —
  automatic recomputation is paused…" depending on whether the
  current order_status is a manual override (OnHold / Cancelled).
- The summary schema renders payment → fulfilment → order_status in
  that order so the reader can see the inputs above the computed
  result.
- Three new infolist lang keys (`payment_status.label`,
  `fulfilment_status.label`, `status.computed`, `status.manual_override`)
  added across all 16 admin locales. `status.label` text changes
  from "Status" to "Order" to disambiguate now that all three are
  shown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old "Update Status" action was a kitchen sink — it could rewrite
order_status directly, included a mailers wizard tied to the deleted
lunar.orders.statuses config, and made no distinction between the
three states. With payment_status now driven exclusively by
transactions (capture / refund), only fulfilment_status should be
admin-editable, and order_status should generally be computed.

Replace the single action with four focused ones in the filament
package:

- UpdateFulfilmentStatusAction — Select bound to
  $record->fulfilment_status->transitionableStates(), calls
  transitionTo() on submit. Hidden when no legal next states exist
  (e.g. order is on Returned).
- PlaceOrderOnHoldAction — sets order_status to OnHold, with a
  confirmation explaining recomputation pauses.
- CancelOrderAction — sets order_status to Cancelled.
- ResumeOrderAction — visible only while order_status is a manual
  override; writes a non-override value so OrderObserver fires
  computeOrderStatus() and lands on the real computed state.

ManageOrder wires these in place of UpdateOrderStatusAction. The
old action class isn't deleted in this commit — UpdateOrderStatus
in core still backs the bulk action used elsewhere — but the muddled
wizard is no longer presented to admins on the order page.

Translations:
- packages/filament/resources/lang: actions.php gains
  update_fulfilment_status / place_on_hold / cancel_order /
  resume_order blocks. The old update_status / wizard block is
  removed (referenced dead lunar.orders.statuses keys). 16 locales.
- packages/admin/resources/lang: the order.infolist.{payment_status,
  fulfilment_status, status.{computed, manual_override}} keys added
  in the previous commit were sitting under .form by mistake. Move
  them under .infolist where the production code reads from. 16
  locales.

Tests updated:
- Replace `it can update order status` with three focused tests:
  `it can place an order on hold`, `it can resume an order from
  on-hold`, `it can transition fulfilment status from the order page`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant