stateforge/scenario-symfony
StateForge Scenario for Symfony integrates StateForge scenarios with Symfony applications, letting you define and run scenario-based workflows, model state transitions, and organize scenario configuration through familiar Symfony services. Ideal for testing and validating stateful behavior.
Install and wire it up
composer require --dev stateforge/scenario-symfony
There is no Flex recipe at this maturity level, so expect to register the bundle manually in config/bundles.php:
return [
// ...
Stateforge\ScenarioSymfony\ScenarioSymfonyBundle::class => ['test' => true, 'dev' => true],
];
Restrict it to test and dev — this package is about mutating application state deterministically, which is the last thing you want reachable in production. If the bundle ships a Configuration class you'll get a config/packages/scenario.yaml; check src/DependencyInjection/ for the actual config tree rather than guessing keys.
Where to look first, in this order
src/ for the attribute classes — the attribute names are the public API and the thing you'll type every day.src/DependencyInjection/ for the compiler pass — this tells you which extension points (tagged services, resolvers, step handlers) actually exist.tests/ in the repo — with 0 stars and 0 dependents this is effectively the only documentation. Read the fixtures to learn the intended usage.stateforge/scenario-core package it adapts — the Symfony package is a thin adapter, so most semantics (scenario lifecycle, ordering, state snapshots) live upstream.First use case: replacing hand-rolled fixtures
The realistic day-one win is a test class that declares its required database/app state inline instead of calling a UserFixtures class or a setUp() full of EntityManager::persist():
#[Scenario('user-with-active-subscription')]
final class BillingTest extends KernelTestCase
{
// ...
}
Verify the exact attribute name and constructor signature against src/Attribute/. The mental model is: an attribute on the class names a scenario, and the core package resolves that name to a definition that provisions state before your test body runs. Once that clicks, the rest of the package is about composing and parameterising those scenarios.
Scenario-per-test-class, deltas for variants. The pattern that scales is one named base scenario (a "known good" application state) plus small per-test overrides. If the attribute accepts arguments, pass only the deltas — e.g. a base scenario that creates a tenant and an admin user, and a per-test argument that flips one user's role. This keeps scenarios readable and avoids a combinatorial explosion of named scenarios.
Layer scenarios with PHPUnit/Symfony lifecycle hooks. Scenario execution should happen after the kernel is booted but before your test body. In KernelTestCase that means the scenario must be resolved inside setUp() (after bootKernel()), not in setUpBeforeClass(). If the package provides a trait or a base test case, prefer it over writing your own hook — the ordering of "boot kernel → run scenario → open transaction" is subtle and the package almost certainly gets it right where a hand-rolled setUp() won't.
Use it as a console command in dev/CI. The adapter's real value outside tests is a command that materialises a named scenario into a dev database — useful for demo environments, reproducing a bug report, or seeding a review app. Look for a command in src/Console/; if one exists, alias it per environment so bin/console scenario:run demo is a single command in your docs.
Compose state via tagged services, not inheritance. If the DI pass collects tagged services, register custom state mutators (an "index the search catalog" step, a "warm the cache pool" step) as small services with the tag rather than subclassing a base scenario class. Tagged services are the idiomatic Symfony extension point and are far easier to test in isolation.
Pair with transaction wrapping for speed. Scenarios that touch Doctrine should run inside a transaction that is rolled back after each test, provided the scenario only issues DML. If a scenario truncates tables or runs DDL, the rollback strategy breaks — decide per scenario which isolation model applies and document it, because the failure mode (a test seeing the previous test's rows) is confusing.
Determinism as a habit. Whatever the scenario provisions, pin the non-deterministic bits yourself: a frozen clock, a fixed random_int seed, explicit UUIDs. The core package can make state reproducible, but it can't stop your fixtures from calling new \DateTimeImmutable() and producing a flaky assertion on a timestamp.
Kernel reboots invalidate the scenario. If anything in your test calls self::ensureKernelShutdown() and reboots, you're back to an empty container and the provisioned state is gone — or worse, the scenario re-runs on top of existing state. Provision once, after the final boot, and don't reboot mid-test.
Attribute discovery and caching. If the package scans directories for scenario definitions or attribute-bearing classes, it will have a cache. In test/dev that cache should be off; if you see "scenario not found" after adding a new one, clear the cache before you start debugging your code. Check whether the discovery paths are configurable — if they default to tests/ only, adding a scenario under src/ silently does nothing.
Idempotency is not guaranteed. A declarative scenario that "creates a user" usually means create, not upsert. Running the same scenario twice against a persistent dev database will either throw a unique-constraint violation or leave duplicates. For dev/CI console usage, either reset the database first or verify that the core package supports a truncate/reset mode.
Database isolation model mismatch. SQLite :memory: and Postgres behave differently under truncation and transaction rollback. If your test suite is multi-connection, scenarios that provision across connections won't roll back together, and the leak is invisible until a later test fails for no apparent reason. Prefer one connection for scenario-managed data.
Caches and compiled containers survive scenarios. cache.app, the HTTP cache, the router matcher, and Doctrine's second-level cache all persist state that a "reset application state" scenario may not know about. If a scenario changes configuration that's baked into the compiled container, it cannot take effect without a reboot — don't try.
Version pinning given the maturity. Zero stars, zero dependents, and a first release dated in the future relative to typical adoption — treat the API as pre-stable. Pin the exact version in composer.json ("stateforge/scenario-symfony": "0.x.y"), and be ready to read vendor/ to understand behaviour. If the package matters to your test suite, consider a thin internal wrapper class so a breaking upstream rename is a one-file change.
Debugging workflow. Two useful levers: run the suite with the highest verbosity the package supports to see the scenario execution log (which scenario, which steps, in what order), and inspect the database immediately after setUp() with a deliberate dump() before your test body. Nearly all failures fall into three buckets — attribute not discovered, scenario ordering wrong, or state provisioned after the assertion-relevant data was already read by a cached service.
Extension points to reach for, in preference order. Tagged services (if the compiler pass registers a tag) → configuration keys for discovery paths and cache → custom resolver/runner services → forking the package. Subclassing the bundle itself is almost never the right answer; the adapter is deliberately thin and the interesting behaviour lives in stateforge/scenario-core.
How can I help you explore Laravel packages today?