Spec 0021 — State machines#2501
Draft
glennjacobs wants to merge 17 commits into
Draft
Conversation
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>
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements spec 0021 —
spatie/laravel-model-statesv2 across core:PaymentState(5 states),FulfilmentState(7),OrderState(11) withOnHold/Cancelledas manual overrides.OrderObserverrewritten to dispatchOrderStatusUpdatedon payment/fulfilment changes, log activity, and resume computation when leaving a manual-override state.SendOrderStatusNotificationslistener fan-outs to per-state notifications.Retires
SoftDeletesonProduct,ProductVariant,Channel,Collection— the newArchived/Inactivestates replace the soft-delete-as-archive pattern.CartandStaffkeep 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 boundOrderStateConfigat construction time — the contract is the single seam for everything order-state-related. Consumers extendDefaultOrderStateConfig, add states + transitions + notifications + resolver overrides, and bind the subclass in their service provider:OrderStateConfigExtensionTestproves this works end-to-end — a custom config addsPartiallyCaptured, transitions into it, and round-trips through the cast without any core change.Bind in
register()(notboot()): Spatie'sStatebase 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).DefaultOrderStateConfigreads fromlunar.orders.notifications.{name}, so the simple "drop a class in config" case still works:Consumers needing programmatic resolution (database, feature flags, per-customer-group) override
notificationsFor()in their subclass.OrdergainsIlluminate\Notifications\Notifiableso$order->notify()works directly.Breaking changes
The public-contract changes will land in the upgrade package's Rector rules as a follow-up:
Order::$statusremoved; replaced bypayment_status,fulfilment_status,order_status(state-cast).Order::getStatusLabelAttribute()removed; use$order->order_status->label().SoftDeletesremoved fromProduct,ProductVariant,Channel,Collection—withTrashed()/restore()callers must move to the relevant state.lunar.orders.statusesconfig block removed; replaced bylunar.orders.notifications(different shape, scoped to notifications).MarkOrderAsShippedaction no longer takesUpdatesOrderStatusas a collaborator — transitionsfulfilment_statusdirectly.UpdateOrderStatusaction signature unchanged but now mutatesorder_statusand validates against the registeredOrderStatecatalogue rather thanlunar.orders.statusesconfig.Tooling
Adding
spatie/laravel-model-statesexposed 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 shapefix-code-style.yml,document-facades.yml— bumped single-version 8.3 → 8.4User-facing impact: anyone on PHP 8.3 cannot install v2 after this lands.
Test plan
vendor/bin/pint --dirty --format agent— passvendor/bin/phpstan analyse --no-progress— no errorsvendor/bin/pest --testsuite=core— 689 passed, 1 skipped (incl. 78 new tests undertests/core/Unit/States/andtests/core/Unit/Listeners/)vendor/bin/pest --testsuite=admin— 189 passedvendor/bin/pest --testsuite=filament— 40 passedvendor/bin/pest --testsuite=stripe— 40 passedvendor/bin/pest --testsuite=shipping— 98 passedvendor/bin/pest --testsuite=search— 4 passedvendor/bin/pest --testsuite=upgrade— 39 passedOrderStateConfigExtensionTestSendOrderStatusNotificationsTesthttps://lunar-v2.test🤖 Generated with Claude Code