{"id":50919714,"url":"https://github.com/llbbl/esm","last_synced_at":"2026-06-16T18:32:19.818Z","repository":{"id":364113090,"uuid":"1266439169","full_name":"llbbl/esm","owner":"llbbl","description":"Zero-configuration, framework-agnostic PHP state machine driven by Enums and Attributes.","archived":false,"fork":false,"pushed_at":"2026-06-11T16:44:52.000Z","size":58,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-11T18:21:40.886Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/llbbl.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-11T16:07:07.000Z","updated_at":"2026-06-11T16:44:57.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/llbbl/esm","commit_stats":null,"previous_names":["llbbl/esm"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/llbbl/esm","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llbbl%2Fesm","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llbbl%2Fesm/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llbbl%2Fesm/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llbbl%2Fesm/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/llbbl","download_url":"https://codeload.github.com/llbbl/esm/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/llbbl%2Fesm/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34419047,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-16T02:00:06.860Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-16T18:32:13.713Z","updated_at":"2026-06-16T18:32:17.663Z","avatar_url":"https://github.com/llbbl.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# enum-state-machine\n\nZero-configuration, framework-agnostic PHP state machine driven by **Enums** and **Attributes**.\n\nInstead 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.\n\n```php\nuse EnumStateMachine\\Attributes\\Transition;\nuse EnumStateMachine\\Attributes\\StateMachineConfig;\n\n#[StateMachineConfig(dispatchEvents: true)]\n#[Transition(to: self::Cancelled, guard: NotYetShippedGuard::class)] // wildcard: any state → Cancelled\nenum OrderState: string\n{\n    case Pending = 'pending';\n\n    #[Transition(to: self::Processing, after: ChargeCreditCardHook::class)]\n    case Paid = 'paid';\n\n    #[Transition(\n        to: self::Shipped,\n        guard: HasValidAddressGuard::class,\n        before: ReserveInventoryHook::class,\n        after: SendTrackingEmailHook::class,\n    )]\n    case Processing = 'processing';\n\n    case Shipped = 'shipped';\n    case Cancelled = 'cancelled';\n}\n```\n\n```php\nuse EnumStateMachine\\StateMachine;\nuse EnumStateMachine\\Exceptions\\InvalidTransitionException;\n\n// Construct from your model's enum-typed state (`$order-\u003estate` is typed\n// `OrderState`) for full PHPStan narrowing of `transitionTo()` / `getCurrentState()`.\n$machine = new StateMachine($order-\u003estate);\n\nif ($machine-\u003ecan(OrderState::Shipped, context: $order)) {\n    // show the \"Ship\" button\n}\n\ntry {\n    $order-\u003estate = $machine-\u003etransitionTo(OrderState::Shipped, context: $order);\n} catch (InvalidTransitionException $e) {\n    // no such transition, or a guard rejected it\n}\n```\n\n## Core principles\n\n- **The enum is the truth** — transitions, guards, and side-effects live on the enum case.\n- **Strictly typed** — full IDE autocompletion and PHPStan/Psalm support.\n- **Agnostic** — vanilla PHP, Laravel, Symfony, anything. Optional PSR-11 container (DI for guards/hooks) and PSR-14 dispatcher (transition events); neither is required.\n\n## Requirements\n\n- PHP **\u003e= 8.1** (Enums + Attributes)\n- Optional: `psr/container` (resolve guards/hooks via DI), `psr/event-dispatcher` (emit transition events)\n\n## Installation\n\n```bash\ncomposer require llbbl/enum-state-machine\n```\n\n## Concepts\n\n| Piece | Role |\n| --- | --- |\n| `#[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. |\n| `#[StateMachineConfig(dispatchEvents, event)]` | Class-level config (event dispatching toggle / custom event). |\n| `GuardInterface` | `__invoke($from, $to, $context): bool` — vetoes a transition. Must be side-effect-free (also run by `can()`). |\n| `StateHookInterface` | `__invoke($from, $to, $context): void` — `before`/`after` side-effects. |\n| `StateTransitioned` | PSR-14 event emitted after a successful transition. |\n| `StateMachine` | The engine: `can()`, `transitionTo()`, `getCurrentState()`. |\n\nOrder 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`.\n\n## Development setup\n\nThis repo uses [**mise**](https://mise.jdx.dev) to pin PHP and [**just**](https://github.com/casey/just) as the task runner.\n\n### 1. Bootstrap the toolchain (macOS, one-time)\n\n```bash\njust php setup      # Homebrew C libs + builds the pinned PHP against openssl@3\njust install        # composer install\n```\n\nThe PHP toolchain recipes live in a `php` module (`just php \u003crecipe\u003e`):\n\n| Recipe | Purpose |\n| --- | --- |\n| `just php setup` | One-time bootstrap (Homebrew libs + build the pinned PHP). |\n| `just php version` | Print the active PHP version. |\n| `just php latest` | Latest stable (non-RC) PHP vs the current pin. |\n| `just php floor` | Oldest php.net **active-support** PHP — the compatibility floor. |\n| `just php bump [VERSION]` | Repin mise.toml to the latest stable (or an explicit version); refuses RCs. |\n\n`just php setup` compiles PHP from source via mise. Two macOS gotchas it handles for you:\n\n- **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`.\n- **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.\n\n\u003e 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`.\n\n### 2. Everyday commands\n\n```bash\njust            # list recipes\njust test       # run the Pest suite\njust stan       # PHPStan at max level\njust qa         # stan + test (the quality gate)\njust coverage   # tests with coverage\n```\n\nAll recipes run through the mise-pinned PHP.\n\n### 3. Testing on older PHP (8.1–8.3)\n\nThe library supports PHP **\u003e= 8.1**, but the dev toolchain doesn't: Pest 3 pulls\nin `symfony/*` v8, which requires PHP **\u003e= 8.4.1**. So the committed\n`composer.lock` only installs on 8.4+, and GitHub CI runs the full suite on the\n**currently-supported** versions (8.4 and 8.5) with a reproducible\n`composer install`.\n\nOlder versions are exercised **locally via Docker**, where each version resolves\nits own compatible dependency set:\n\n```bash\njust legacy all        # 8.1 (lint) + 8.2 + 8.3 (full Pest suite)\njust legacy run 8.2    # a single version\njust legacy build      # rebuild the images after editing docker/Dockerfile\n```\n\n- **8.2 / 8.3** run the full Pest suite — the container does a `composer update`\n  (resolving `symfony` v7, which Pest 3 also supports) before testing.\n- **8.1** can't install Pest 3 at all, so it only syntax-checks `src/` (`php -l`)\n  to defend the runtime floor.\n\nThe repo is mounted **read-only** and copied inside the container, so a legacy\nrun's `composer update` never rewrites your 8.4-resolved host `composer.lock`.\nRequires Docker (`docker compose`); the recipes live in the `legacy` module\n(`compose.yaml` + `docker/`).\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fllbbl%2Fesm","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fllbbl%2Fesm","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fllbbl%2Fesm/lists"}