Skip to content

0039. Model connection status as a state machine

Date: 2026-09-30 Status: Accepted

Context

SystemConnection.status was the backed enum SystemConnectionStatus. Every writer set it directly: the admin panel actions, the plugin's disconnect and uninstall endpoints, SystemConnectionService::activate(), and two sync jobs. SystemConnectionObserver::updated() matched on the new status and ran the side effects that belong to it. A saving() guard refused to leave Uninstalled. Nothing else defined which status changes were legal.

Checking every path against that arrangement found four problems.

An opened circuit breaker overwrote a connection that had just ended. BulkSyncJob and FetchBulkPageRangeJob check isHealthy() when they start. They then probe the shop, which can take seconds. When the circuit breaker opened during that probe, they set Error through a query-builder update, which no model event sees. If the connection was disconnected in the meantime, Disconnected became Error. That had a chain of consequences:

  • disconnected_at stayed set, but prunable() selects only Disconnected rows, so the row was never purged.
  • DisconnectConnectionDataJob found Error when it ran and left the business data live.
  • A reconnect within the grace period found no Disconnected sibling. It retired the old row to Inactive and re-imported the shop instead of restoring its data.

The same race overwrote Inactive and Uninstalled. ADR-0013 had already named this window in its consequences.

Error was a dead end. The circuit breaker closes after its cooldown, but the status stayed Error. Webhooks and syncs were discarded until the merchant completed a fresh handshake. ADR-0009 chose manual recovery, but the admin panel offered no manual action.

The create form bypassed the side effects. The Filament create form offered every status. The observer reacted on creation only to Active, so a row created as Uninstalled was never purged.

The Uninstalled guard compared the in-memory original, not the stored row. A stale model instance could still overwrite Uninstalled.

Decision

States on spatie/laravel-model-states

SystemConnection.status is a spatie/laravel-model-states state. The abstract base is App\States\SystemConnection\SystemConnectionState. The six concrete states are Pending, Active, Inactive, Disconnected, Error and Uninstalled.

Each state's $name alias is the string the column already held (pending, active, and so on). The column therefore keeps its values, there is no migration, and the API returns the same status strings as before. The base class carries what the enum carried: the Filament label and colour, allowsDataFlow(), hasGracePeriod(), and a static options() for select filters. The enum is deleted.

One transition class per move

Every legal move has its own class in App\States\SystemConnection\Transitions. The table is declared once, in SystemConnectionState::config():

Transition From To Side effects
Activate Pending Active resets the circuit breaker, generates default sync rules, stamps last_connected_at
Reconnect Disconnected Active as Activate, plus RestoreConnectionDataJob with the previous disconnected_at
Reactivate Error Active resets the circuit breaker, generates missing default sync rules
Deactivate Pending, Active, Error Inactive cancels in-progress batches, revokes all tokens, NotifyConnectionDeactivatedJob, CleanupConnectionDataJob
Disconnect Pending, Active, Error Disconnected cancels in-progress batches, DisconnectConnectionDataJob
RecordError Active Error cancels in-progress batches
Uninstall every state but Uninstalled Uninstalled cancels in-progress batches, revokes all tokens, clears credentials, PurgeConnectionJob

Reconnect also declares the library's canTransition() precondition: a Disconnected connection may move back to Active only within its grace period, and the base transition checks it again against the locked row.

The table reproduces the moves the old code could reach. The only behaviour it changes is the Error race, which it removes. Four moves are deliberately absent:

  • Inactive to Active. The way back stays a fresh handshake, which creates a new row.
  • Inactive to Disconnected. Unreachable: an inactive connection's tokens are revoked, so the plugin cannot call the endpoint.
  • Error to Pending. No caller needs it.
  • Anything out of Uninstalled. Uninstall is final.

Creation is not a transition. A new row defaults to Pending. The tenant bootstrap and SyncRuleSeeder create Active rows directly, and the observer's created() hook generates their sync rules.

Our own base transition for the row lock and the re-check

The library checks the table only inside transitionTo(). It checks against the state held in memory, and it takes no lock. A direct assignment or a query-builder write passes unchecked, and a stale instance passes the check with a state the row no longer has. That is exactly the shape of the Error race.

Every transition therefore extends the abstract SystemConnectionTransition, whose handle() is final:

  1. It refuses a model with unsaved changes by throwing a LogicException. The reload in the next step would silently drop them. Callers save other fields first and then transition.
  2. It opens a transaction and reloads the row into the same instance with Laravel's refreshForUpdate(), which selects it with lockForUpdate().
  3. If the stored state already is the target, it returns without saving and without side effects.
  4. If the stored state may not move to the target, it calls refused(). By default that throws StaleConnectionStatus, a CouldNotPerformTransition. It also calls refused() when the table resolves the stored move to a different transition class than the one running. SystemConnectionState::canTransitionToVia() answers both questions at once.
  5. Otherwise it runs the transition's prepare() hook, sets the target state, saves, and runs the transition's sideEffects() through the container, so each transition declares the services it needs as parameters.

Same-state calls

The table deliberately has no ignoreSameState(). A transitionTo() into the state the instance already holds therefore throws at the library's in-memory check, before the base transition runs.

The callers that can legitimately repeat a move handle that themselves. The disconnect and uninstall endpoints return early when the connection already is in the target state, so both stay idempotent. The base transition's own no-op in step 3 covers only the remaining case: the stored row already moved to the target behind a stale instance.

RecordError keeps a lost race

RecordError overrides refused(). When the connection left Active before the error could be recorded, it logs at info and keeps the stored state. A connection disconnected, deactivated or uninstalled while a sync was probing the shop is an expected race, not a fault. This removes the Error race described above.

A guard against status changes outside a transition

SystemConnectionObserver::saving() throws App\Exceptions\StatusChangeRequiresTransition when an existing row's status is dirty and no transition is running. The base transition lifts the guard only for its own save, through SystemConnection::allowingStatusChange(). That method has to be public for the transition to reach it, so an architecture test makes sure the base transition is its only caller. Creation is exempt, so a new row may start in any state.

The guard can only see saves that fire model events. A query-builder update, saveQuietly() and withoutEvents() all bypass it. The architecture guideline therefore forbids all three for the status column. The three query-builder status writes that existed were replaced by transitions.

Side effects inside the transaction

Every side effect lives in its transition, and the transition's transaction wraps both the status and the side effects. The synchronous ones, such as batch cancellation, token revocation and clearing credentials, roll back together with the status.

Jobs are dispatched with ->afterCommit(). Laravel defers them to the commit of the outermost transaction, which includes the one SystemConnectionService::activate() wraps around the whole handshake, and discards them on rollback. No job therefore reads a status that was never committed.

Default sync rule generation is deferred the same way, through DB::afterCommit(). It logs and swallows its own failures. Run inside the transaction, a MySQL deadlock that rolled back the whole transaction would be swallowed, and the rest of the handshake would carry on in autocommit.

The observer keeps only what is not a status side effect: the guard, the default configuration, the disconnected_at invariant, and rule generation in created() and restored(). Its updated() hook and the match in it are removed.

A manual way out of Error

ReactivateConnectionAction moves an Error connection back to Active through Reactivate. It sits in the admin panel next to Deactivate, Disconnect and Force Uninstall, asks for confirmation, and is visible only for an Error connection the user may update. It starts no sync itself: the next scheduled delta sync picks the connection up.

Each of the four status actions is visible only while its own transition can run from the connection's status, which SystemConnectionState::canTransitionToVia() answers. A click on an action whose status changed since the page rendered ends with a failure notification and the page shows the current status, instead of doing nothing or showing an error page. The create form no longer offers a status, so a connection created in the panel always starts Pending.

A move the API refuses answers with 409 and INVALID_STATUS_TRANSITION, instead of the 500 the old Uninstalled guard produced.

The alternatives that were not taken

A lifecycle service on top of the enum. One service method per move would have kept the enum and needed no dependency. It was rejected for two reasons. A state machine library is a pattern a new reader recognises, and its table is one declaration rather than conditions spread over a service. One class per transition also keeps each move's side effects in one place, with the services it needs injected where it runs.

ignoreSameState(). It would have made same-state calls harmless without early returns in the callers. With it, though, the library routes a same-state call to its own DefaultTransition, which bypasses the base transition and its row lock. It also makes canTransitionTo() true for the current state, so Force Uninstall, whose visibility asks canTransitionTo(Uninstalled::class), would appear on a row that is already Uninstalled.

Consequences

Legal status changes are declared in one place, and a stale instance can no longer overwrite a state the row has since left. The re-check under the lock asks whether the stored state may move to the target and also whether the table resolves that move to the transition class that is running. Three transitions lead to Active, so a stale Error copy would otherwise run Reactivate on a row that is Disconnected by now. The Error race is gone, and Error has a way out.

The type of $status depends on help from outside larastan. Larastan alone infers mixed, because the library's castUsing() declares no return type. The ide-helper model mixin resolves it, and the model also carries an explicit @property SystemConnectionState $status, so the type does not depend on composer ide-helper having run.

The rule against writing the status column past a transition is enforced by the guard for ordinary saves only. Query-builder updates, saveQuietly() and withoutEvents() are forbidden by guideline, and nothing fails automatically when one of them is used.

The library fires its StateChanged event after every handle(), including the same-state no-op of step 3 and RecordError's log-only refusal. In both cases the status did not change. Nothing listens to the event today. A future listener has to compare the states itself rather than trust that the event means a change.

A transition holds the row lock for the length of its synchronous side effects. That is short today: a few deletes and batch cancellations. A slow side effect added later would hold the lock just as long. Tests run on SQLite, where lockForUpdate() does nothing, so race tests simulate the race with a stale model instance rather than with concurrency.

Inactive has no direct way back. Leaving it takes a fresh handshake, which creates a new row, or an uninstall. Nothing purges an orphaned Inactive row, so it keeps the shop's credentials and its soft-deleted data indefinitely. How long such rows are retained, and whether a reconnect should reuse the old row, is a follow-up.

Recovery from Error stays manual. An automatic recovery, such as a scheduled probe of every Error connection, is a follow-up.

This record supersedes the statement in ADR-0013 that the trigger stays bound to the status transition in the observer. The guarantee it served, the same clean-up for every path that ends a connection, now holds because the transitions are the only way to change the status. It amends ADR-0009 with the manual Reactivate action as the recovery from Error.