tienvx/mbt-bundle
tienvx/mbt-bundle is a Symfony bundle for model-based testing in PHP. Define state-machine models, generate executable test cases, and run them with PHPUnit to add structured MBT coverage to existing test suites.
Know what you're installing first. tienvx/mbt-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no config/mbt.php, and no facade. It is the engine behind Sicope Model — you draw a state machine (places + transitions) in the Sicope web UI, export or hand-write it, and this bundle turns that model into runnable PHPUnit test cases. Treat it as a dev-only dependency, not a runtime one.
Minimal steps to get a first result:
composer require --dev tienvx/mbt-bundle symfony/workflow — the Symfony Workflow component is the engine that actually walks your model, and you must pull it in explicitly.Model — your state machine definition (places, transitions, metadata).Test — the generated test representation.TestGenerator / ModelGenerator — the code that emits PHPUnit classes from a model.src/Command — run bin/console list mbt (or php artisan list if you've registered the bundle in a Laravel app, see below) to see what's actually available in your pinned version.If you're in a Laravel app, the cheapest bridge is a standalone Symfony console kernel in tools/mbt/ (or a separate composer.json in a tests/mbt subdir). Registering a Symfony bundle inside artisan is possible but almost never worth it — you gain nothing from the console plumbing and lose an afternoon to Kernel bootstrapping.
Model-as-code, not model-as-drawing. The tempting workflow is "design in Sicope, export, generate." In practice teams converge on committing the model as a PHP class and using Sicope only for visualization. The model file then becomes reviewable in PRs, diffable, and greppable — which the exported JSON is not.
Page objects as the transition layer. The bundle generates the walk (which transitions fire, in what order); your job is supplying a callable per transition that actually drives the app. Keep these thin:
// tests/Mbt/LoginFlow.php
class LoginFlow extends Model
{
public function transitions(): array
{
return [
'submit_credentials' => fn (TestCase $t) => $t->post('/login', [...]),
'see_dashboard' => fn (TestCase $t) => $t->assertSee('Dashboard'),
'logout' => fn (TestCase $t) => $t->post('/logout'),
];
}
}
Then the generated test just sequences calls. This keeps model changes and UI changes in separate commits.
Use Laravel's HTTP test client, not Mink/Selenium, unless you need JS. The bundle is unopinionated about the driver. For a Laravel app, $this->actingAs($user)->get(...) and $this->post(...) inside a RefreshDatabase test is far faster and less flaky than bolting on a browser. Reserve a browser driver for the flows that genuinely require JS.
Generate, then curate. Don't regenerate in a loop on every CI run. Generate once, review the emitted test, then either commit it or wrap it in a thin subclass that adds your RefreshDatabase trait, factories, and seeding. Generated tests are scaffolding — the moment you hand-edit them, treat regeneration as a migration, not a build step.
Seed and control the walk. Model-based testing's whole selling point is exploring paths you wouldn't write by hand, but unseeded random walks produce non-reproducible failures. Fix the seed in config (or the model's metadata) and log it on failure so a red build is replayable.
Layer assertions in two places. Assert transition preconditions inside the transition callables (did the page actually load?), and assert end-state invariants after the walk. Cramming everything into the callable makes failures point at the walker instead of your app.
CI integration. Run generated tests as a normal PHPUnit suite (--testsuite=mbt or a separate phpunit.mbt.xml). Mark them non-blocking initially — model-based suites are noisier than hand-written ones while the model is under-built.
No Laravel support, and that's the headline. No service provider, no publishable config, no artisan commands out of the box. Every integration point is a small amount of glue you write and own. Budget for that.
Stale by design. Last release July 2022, ~18 stars, near-solo maintenance. Pin an exact version in composer.json and read the changelog before bumping. Symfony 6.3+/PHP 8.3 compat is not guaranteed in older tags — check the composer.json constraints in the tag you install, not the docs.
Symfony Workflow is not optional and not forgiving. Your model must declare an explicit marking_store and an initial marking; an empty initial state silently produces a test that walks nothing and passes. If a generated test "passes instantly," suspect the marking first.
Isolate state between transitions. The walker fires many requests in a single PHPUnit test. Any transition that writes to the DB will contaminate the next unless you reset per-transition or design transitions to be idempotent. RefreshDatabase per test method is not enough here — it only runs once for the whole walk.
Class-name collisions. The package's Test and Model classes are generic enough to clash with PHPUnit's TestCase and Laravel's own test stubs. Always import with aliases (use Tienvx\MbtBundle\Model\Model as MbtModel;).
Debugging a walk: dump the graph. Before blaming your transitions, confirm the graph is what you think it is. Use the package's graph/dump command if it exists in your version, or wire Symfony Workflow's GraphvizDumper — a rendered PNG resolves "why did it never reach checkout?" in seconds.
Extension points worth knowing: custom model contexts (to inject fixtures/factories), event hooks around transition execution (useful for logging the current marking on failure), and the generators themselves — forking TestGenerator to emit a RefreshDatabase trait and your base test class is a legitimate, low-risk customization.
Don't model your whole app. Coverage-per-model drops sharply once a model exceeds ~10 places. Several small models (auth, cart, billing) compose better, generate smaller tests, and fail with clearer messages than one hero model.
Verdict for a Laravel team: worth a spike if you have a stable, well-understood flow with expensive combinatorial paths (checkout permutations, permission matrices). Not worth it for CRUD — the glue-to-value ratio flips against you.
How can I help you explore Laravel packages today?