PHPackages                             llbbl/enum-state-machine - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. llbbl/enum-state-machine

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

llbbl/enum-state-machine
========================

Zero-configuration, framework-agnostic PHP state machine driven by Enums and Attributes.

v0.1.0(1mo ago)100MITPHPPHP &gt;=8.1CI passing

Since Jun 11Pushed 1mo agoCompare

[ Source](https://github.com/llbbl/esm)[ Packagist](https://packagist.org/packages/llbbl/enum-state-machine)[ Docs](https://github.com/llbbl/esm)[ RSS](/packages/llbbl-enum-state-machine/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (1)Dependencies (4)Versions (2)Used By (0)

enum-state-machine
==================

[](#enum-state-machine)

Zero-configuration, framework-agnostic PHP state machine driven by **Enums** and **Attributes**.

Instead of bloated configuration arrays, you declare transitions, guards, and side-effects directly on the enum that represents your state — so the enum *is* the documentation, fully typed and statically analyzable.

```
use EnumStateMachine\Attributes\Transition;
use EnumStateMachine\Attributes\StateMachineConfig;

#[StateMachineConfig(dispatchEvents: true)]
#[Transition(to: self::Cancelled, guard: NotYetShippedGuard::class)] // wildcard: any state → Cancelled
enum OrderState: string
{
    case Pending = 'pending';

    #[Transition(to: self::Processing, after: ChargeCreditCardHook::class)]
    case Paid = 'paid';

    #[Transition(
        to: self::Shipped,
        guard: HasValidAddressGuard::class,
        before: ReserveInventoryHook::class,
        after: SendTrackingEmailHook::class,
    )]
    case Processing = 'processing';

    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}
```

```
use EnumStateMachine\StateMachine;
use EnumStateMachine\Exceptions\InvalidTransitionException;

// Construct from your model's enum-typed state (`$order->state` is typed
// `OrderState`) for full PHPStan narrowing of `transitionTo()` / `getCurrentState()`.
$machine = new StateMachine($order->state);

if ($machine->can(OrderState::Shipped, context: $order)) {
    // show the "Ship" button
}

try {
    $order->state = $machine->transitionTo(OrderState::Shipped, context: $order);
} catch (InvalidTransitionException $e) {
    // no such transition, or a guard rejected it
}
```

Core principles
---------------

[](#core-principles)

- **The enum is the truth** — transitions, guards, and side-effects live on the enum case.
- **Strictly typed** — full IDE autocompletion and PHPStan/Psalm support.
- **Agnostic** — vanilla PHP, Laravel, Symfony, anything. Optional PSR-11 container (DI for guards/hooks) and PSR-14 dispatcher (transition events); neither is required.

Requirements
------------

[](#requirements)

- PHP **&gt;= 8.1** (Enums + Attributes)
- Optional: `psr/container` (resolve guards/hooks via DI), `psr/event-dispatcher` (emit transition events)

Installation
------------

[](#installation)

```
composer require llbbl/enum-state-machine
```

Concepts
--------

[](#concepts)

PieceRole`#[Transition(to, guard, before, after, includeSelf)]`Declares an allowed transition. On a **case** = from that state; on the **enum class** = wildcard from any state. Repeatable.`#[StateMachineConfig(dispatchEvents, event)]`Class-level config (event dispatching toggle / custom event).`GuardInterface``__invoke($from, $to, $context): bool` — vetoes a transition. Must be side-effect-free (also run by `can()`).`StateHookInterface``__invoke($from, $to, $context): void` — `before`/`after` side-effects.`StateTransitioned`PSR-14 event emitted after a successful transition.`StateMachine`The engine: `can()`, `transitionTo()`, `getCurrentState()`.Order of a transition: **guards** → **before hooks** → state changes → **after hooks** → event. Guards/before-hook failures propagate raw and leave state unchanged; an after-hook failure leaves the state changed but throws `HookExecutionException`.

Development setup
-----------------

[](#development-setup)

This repo uses [**mise**](https://mise.jdx.dev) to pin PHP and [**just**](https://github.com/casey/just) as the task runner.

### 1. Bootstrap the toolchain (macOS, one-time)

[](#1-bootstrap-the-toolchain-macos-one-time)

```
just php setup      # Homebrew C libs + builds the pinned PHP against openssl@3
just install        # composer install
```

The PHP toolchain recipes live in a `php` module (`just php `):

RecipePurpose`just php setup`One-time bootstrap (Homebrew libs + build the pinned PHP).`just php version`Print the active PHP version.`just php latest`Latest stable (non-RC) PHP vs the current pin.`just php floor`Oldest php.net **active-support** PHP — the compatibility floor.`just php bump [VERSION]`Repin mise.toml to the latest stable (or an explicit version); refuses RCs.`just php setup` compiles PHP from source via mise. Two macOS gotchas it handles for you:

- **openssl** — the mise php plugin hardcodes the EOL `openssl@1.1`, which yields a PHP with no working TLS wrapper (Composer-over-https breaks). The recipe points the build at `openssl@3` via `brew --prefix`.
- **link libs** — it installs `gd`, `icu4c`, `libzip`, `oniguruma`, `libxml2` (the libs the build force-links) so `./configure` doesn't abort. The state machine uses none of these at runtime; they're build-time link deps of the PHP binary.

> If the build stops at a `checking for … no` line for a different lib, install the matching Homebrew formula and re-run `just php setup`.

### 2. Everyday commands

[](#2-everyday-commands)

```
just            # list recipes
just test       # run the Pest suite
just stan       # PHPStan at max level
just qa         # stan + test (the quality gate)
just coverage   # tests with coverage
```

All recipes run through the mise-pinned PHP.

### 3. Testing on older PHP (8.1–8.3)

[](#3-testing-on-older-php-8183)

The library supports PHP **&gt;= 8.1**, but the dev toolchain doesn't: Pest 3 pulls in `symfony/*` v8, which requires PHP **&gt;= 8.4.1**. So the committed `composer.lock` only installs on 8.4+, and GitHub CI runs the full suite on the **currently-supported** versions (8.4 and 8.5) with a reproducible `composer install`.

Older versions are exercised **locally via Docker**, where each version resolves its own compatible dependency set:

```
just legacy all        # 8.1 (lint) + 8.2 + 8.3 (full Pest suite)
just legacy run 8.2    # a single version
just legacy build      # rebuild the images after editing docker/Dockerfile
```

- **8.2 / 8.3** run the full Pest suite — the container does a `composer update`(resolving `symfony` v7, which Pest 3 also supports) before testing.
- **8.1** can't install Pest 3 at all, so it only syntax-checks `src/` (`php -l`) to defend the runtime floor.

The repo is mounted **read-only** and copied inside the container, so a legacy run's `composer update` never rewrites your 8.4-resolved host `composer.lock`. Requires Docker (`docker compose`); the recipes live in the `legacy` module (`compose.yaml` + `docker/`).

License
-------

[](#license)

MIT

###  Health Score

35

—

LowBetter than 77% of packages

Maintenance90

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity32

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

45d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/19964?v=4)[Logan Lindquist Land](/maintainers/llbbl)[@llbbl](https://github.com/llbbl)

---

Top Contributors

[![llbbl](https://avatars.githubusercontent.com/u/19964?v=4)](https://github.com/llbbl "llbbl (4 commits)")

---

Tags

phpenumworkflowattributesfsmstate-machine

###  Code Quality

TestsPest

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/llbbl-enum-state-machine/health.svg)

```
[![Health](https://phpackages.com/badges/llbbl-enum-state-machine/health.svg)](https://phpackages.com/packages/llbbl-enum-state-machine)
```

###  Alternatives

[pwm/s-flow

A lightweight library for defining state machines

742.5k](/packages/pwm-s-flow)[shrink0r/workflux

Finite state machine for php.

375.6k1](/packages/shrink0r-workflux)[tarfin-labs/event-machine

Event-driven state machines for Laravel with event sourcing, type-safe context, and full audit trail.

199.4k](/packages/tarfin-labs-event-machine)[xentixar/workflow-manager

A workflow manager plugin for FilamentPHP with PHP enum support.

121.5k](/packages/xentixar-workflow-manager)[tamkeen-tech/laravel-enum-state-machine

Control your state using enums

152.1k](/packages/tamkeen-tech-laravel-enum-state-machine)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
