macpaw/behat-orm-context
Behat ORM Context by MacPaw provides Doctrine ORM-aware Behat contexts for acceptance testing in PHP projects. It helps manage entities, fixtures, and database state, keeping scenarios isolated and readable while reducing boilerplate around test data setup and cleanup.
This is a Symfony bundle that ships pre-built Behat contexts for Doctrine ORM work: steps to create/persist entities from Gherkin tables, look up the "current" entity, and assert what's in the database. The point is to stop hand-writing I have a User named "bob" with email "..." steps in every project.
Install and wire it up (all in require-dev):
composer require --dev macpaw/behat-orm-context:^2.0
Register the bundle in config/bundles.php for the test environment only — this must not load in prod:
return [
// ...
MacPaw\BehatOrmContext\BehatOrmContextBundle::class => ['test' => true],
];
Behat contexts are not autowired by Symfony by default. Unless the bundle ships its own Behat extension, you need friends-of-behat/symfony-extension so the kernel (and therefore the EntityManager) is available to your context:
# behat.yml
default:
extensions:
FriendsOfBehat\SymfonyExtension:
bootstrap: tests/bootstrap.php
kernel:
class: App\Kernel
environment: test
debug: true
suites:
default:
contexts:
- MacPaw\BehatOrmContext\Context\OrmContext # name it exactly as in src/
- App\Tests\Behat\FeatureContext
v2.0.0 compatibility floor (check this first): PHP ^8.1, Symfony 6.4–8 (anything below 6.4 is unsupported), Doctrine ORM 2–3, Behat 3. If your app is still on Symfony 6.0–6.3 you cannot take this release at all — plan that upgrade first, or stay on 1.2.0 and pin it.
Good news for upgraders: the public API is unchanged in 2.0. Step definitions, the config surface, and --definitions output are identical to 1.x, so moving from 1.2 is a composer.json bump, not a feature-file rewrite. If you're on an older Symfony and want the wider Doctrine support, that's the real reason to chase the upgrade.
First thing to do after installing: run
vendor/bin/behat --definitions
That prints the authoritative list of steps the context registers. Given the package's adoption level (2 stars, 0 dependents) and thin public docs, --definitions plus vendor/macpaw/behat-orm-context/src/ is your real documentation. Read the context class top to bottom once — it's a small file, and you'll immediately see which method names and config keys exist.
First use case — a smoke scenario that proves the wiring works:
Feature: Users can be created
Scenario: Persisting a user
Given the following "User" entities exist:
| name | email |
| bob | [email protected] |
Then there should be 1 "User" entity
If that runs green against a real test database, the container, the mapping config, and the EntityManager are all hooked up correctly. Do this before you port over any existing features.
1. Entity name resolution is the core config decision. Most ORM contexts map a short Gherkin name ("User") to a FQCN, either via a bundle config map or via a convention over your Doctrine mapping. Decide this early: an explicit map is boring but survives refactors and namespace moves; convention-based lookup breaks silently the day you introduce App\Domain\User next to App\Legacy\User. Prefer the explicit map.
# illustrative — confirm key names against src/ before copying
macpaw_behat_orm_context:
entities:
User: App\Domain\User\User
Order: App\Domain\Order\Order
2. Put domain-specific steps in your context, not theirs. The package gives you persistence and assertion primitives. The business vocabulary — "a user registered yesterday", "an order with two line items" — belongs in your FeatureContext or a UserSteps trait. Keep the distinction sharp: the bundle talks to Doctrine, your traits talk to your domain.
3. Share entity state via your FeatureContext, not across contexts. If a Given persists an entity and a later Then needs to check it through the HTTP/API layer, get the identifier out of the ORM context and into shared storage. Behat contexts are independent objects by default; a SharedStorage-style object injected into both is the clean way:
final class FeatureContext implements Context
{
public function __construct(
private SharedStorage $storage,
private EntityManagerInterface $em,
) {}
}
4. Combine with Mink for real end-to-end. The typical split: ORM context creates fixtures and asserts DB state, Mink drives the browser, HTTP client context hits the API. Use ORM Givens to set up state (fast, precise), Mink to interact, and ORM Thens only for side effects the UI doesn't show (queue rows, audit log entries, denormalized counters).
5. Isolate scenarios with transactional rollback. If the context flushes to the database, scenarios will leak state into each other and you'll get order-dependent failures that take a day to diagnose. Either wrap every scenario in a transaction (dama/doctrine-test-bundle) or add an explicit purge in @BeforeScenario for the tables the context touches. Do this on day one, not when the flakiness starts.
6. In CI, keep the schema cheap. The bundle requires no services — its own tests mock the EntityManager — but your suite needs a real DB. SQLite in-memory plus a schema-created-once bootstrap is usually enough for ORM-heavy suites; use MySQL/Postgres if your queries use vendor-specific SQL.
7. Table transforms are your friend. Gherkin tables are strings; entities have types, enums, DateTimeImmutable, relations. Write a small normalizer (or a TransformArgument context) that converts "now", "+1 day", enum names, and foreign-key-by-name lookups into real values, so feature files stay readable and step methods stay simple.
8. Target a Doctrine version deliberately, then test the pair you actually ship. 2.0 supports ORM 2 and 3 and DBAL 2 and 4 — the JSON-column handling is the piece that was made version-aware internally. That breadth is convenient, but it means bugs can be version-specific. Pin both the bundle and your ORM in composer.json, and if your suite exercises JSON columns or typed fields through the context, add a job or a documented note for the ORM major you run in CI, so a future bump doesn't surprise you.
friends-of-behat/symfony-extension's supported Symfony range before you celebrate; on Symfony 7/8 that extension is far more likely to be your blocker than this bundle.FeatureContext traits are not automatically.^8.1 floor, and the package pins config.platform.php to 8.1.0 with composer.lock gitignored. The dev tooling now reflects that: PHPUnit ^10.5 || ^11.0 (9 dropped) where 11 needs PHP 8.2, PHPCS ^4.0, Rector ^1 || ^2, Slevomat ^8, PHPStan ^2 with phpstan-doctrine ^2. Useful signal — static analysis now understands Doctrine metadata, and the run-phpunit.sh wrapper always passes PHPUnit 10+ runner-warning flags, so upstream CI is strict. It also means their CI never resolves against your PHP 8.3 lockfile. If you hit a resolution conflict, composer why-not is your first command.vendor/bin/behat --definitions -vvv and grep for your existing custom steps before registering the context. Common clashes: the following ... exist, there should be N ....EntityManager, the FriendsOfBehat\SymfonyExtension isn't bootstrapping the kernel in the test environment, or the context is listed in behat.yml but not registered as a service in services_test.yaml. Behat's own exception messages here are unhelpful — check the extension config first.flush() vs. transactions. If the context flushes per step, @BeforeScenario transaction rollback from dama/doctrine-test-bundle still works, but any test that asserts on auto-increment IDs will be brittle. Assert on business keys (email, slug, reference) rather than IDs.cascade: persist or a mis-typed entity name in the config map can produce a step that "succeeds" but writes nothing. When a Then fails unexpectedly, put die() (or a dump()) in the persist step once to confirm it's actually reaching Doctrine — faster than reading stack traces.--dry-run to check step matching without executing, --append-snippets to generate missing definitions, --strict to turn undefined/pending steps into failures (you want this in CI), -vvv for the kernel boot and query log.behat.yml instead of the vendor class — you keep upstream fixes for the step definitions and only own the parts you changed. Avoid editing vendor/.src/ end to end, pin an exact version, and be prepared to drop the bundle for a ~150-line custom trait if it fights your domain model. The primitives are simple enough that replacing it is cheap; the value is in not writing them yourself on day one.How can I help you explore Laravel packages today?