codeception/module-symfony
Codeception module for Symfony: boots the kernel in functional tests, exposes the service container and Symfony services, and integrates Doctrine data cleanup, mailer and profiler assertions so you can write expressive end-to-end tests. Official module maintained by the Codeception team.
Verdict up front: this is the module you reach for when you want functional acceptance tests against a real Symfony kernel — forms with CSRF, security voters, Doctrine, Mailer, Messenger, the profiler — without standing up HTTP infrastructure. If you only need HTTP-level smoke tests, plain PhpBrowser/REST is lighter. If you need "did the app do the right thing internally," this is the tool.
Install (Codeception 5 / Symfony 5.4–8.1, PHP ^8.2):
composer require --dev codeception/module-symfony codeception/codeception
vendor/bin/codecept init acceptance # or bootstrap manually
Minimal tests/Acceptance.suite.yml:
actor: AcceptanceTester
modules:
enabled:
- Symfony:
app_path: 'src' # 'app' is the Symfony 2/3 default — wrong for modern apps
environment: 'test'
- Doctrine2:
depends: Symfony # REQUIRED: Doctrine2 borrows the kernel from Symfony
cleanup: true
- \Helper\Acceptance
Then vendor/bin/codecept build to regenerate the Actor with the module's methods on it.
Where to look first:
tests/Support/AcceptanceTester.php — after build, autocompletion shows every method the module gives you. This is your real documentation surface.config/packages/test/*.yaml — half of the module's power is gated on framework.test: true, framework.profiler, and a mailer transport set to null:///test.First use case — a login + form submission + DB assertion:
class RegistrationCest
{
public function registerANewUser(AcceptanceTester $I): void
{
$I->amOnRoute('app_register'); // route-aware, no hardcoded URLs
$I->submitSymfonyForm('registration_form', [ // uses the real Symfony Form, CSRF intact
'registration_form[email]' => '[email protected]',
'registration_form[plainPassword]' => 's3cret!',
]);
$I->seeResponseCodeIs(302);
$I->seePageRedirectsTo('/dashboard');
$I->seeFormHasErrors(false);
$user = $I->grabEntityFromRepository(User::class, ['email' => '[email protected]']);
$I->assertSame('ROLE_USER', $user->getRoles()[0]);
$I->seeEmailIsSent(1);
$I->assertEmailSubjectContains($I->grabLastSentEmail(), 'Welcome');
}
}
That single test exercises routing, form validation, CSRF, Doctrine persistence, and transactional email — which is exactly the day-to-day value proposition.
submitSymfonyForm, not submitFormsubmitSymfonyForm('form_name', $values) goes through FormInterface, so CSRF tokens, name-prefixed field keys, and data transformers behave like production. Raw $I->submitForm() (InnerBrowser) posts raw arrays and will fail on any CSRF-protected form. Follow with targeted assertions rather than DOM scraping:
$I->submitSymfonyForm('checkout', $payload);
$I->seeFormHasErrors(true);
$I->seeFormErrorMessage('email', 'This value is not a valid email address.');
$I->dontSeeFormErrors();
$I->seeFormErrorMessages(['email' => '...', 'zip' => '...']); // plural form for multi-field checks
Configure Doctrine2 with depends: Symfony and cleanup: true, then seed per-test:
$I->haveInRepository(Product::class, ['sku' => 'ABC', 'price' => 999]);
$I->seeNumRecords(1, Product::class, ['sku' => 'ABC']);
$product = $I->grabEntityFromRepository(Product::class, ['sku' => 'ABC']);
$repo = $I->grabRepository(Product::class);
Prefer in-test seeding over shared fixture files — it keeps each Cest self-describing and avoids ordering dependencies. Keep one tests/_data fixture set only for genuinely global data (roles, reference tables).
Anything crossing the process boundary (HTTP clients, payment SDKs, mailers, clocks) should be swapped, and the swap must survive the kernel reboot between requests. Newer releases give you exactly that:
$I->mockService('http_client', new MockHttpClient($responses)); // survives reboots
$I->amOnPage('/checkout');
// ...
$I->unmockService('http_client');
$container = $I->grabContainer(); // test container: private services visible
$clock = $I->grabService('clock'); // via the module's service helper
For anything the DI container doesn't own, persistPermanentService() pins an object across requests; use it sparingly — it's the escape hatch, not the pattern.
A full login flow through the form is slow. For Cests that don't test login itself:
loginUser(), or a session/token injected via a persisted service), then$I->seeAuthentication(), $I->dontSeeAuthentication(), $I->seeUserHasRoles(['ROLE_ADMIN']), and for "remember me" the dedicated seeRememberedAuthentication() / dontSeeRememberedAuthentication() (these also verify the remember-me cookie, not just the token).Teardown helpers: $I->logout() (alias of logoutProgrammatically()), $I->goToLogoutPath() when you want to test the real logout route.
seeUserIsGranted('EDIT', $post) executes the app's own voters with a subject — this catches the class of bug where a controller forgets denyAccessUnlessGranted() without you having to reverse-engineer HTTP responses.
$I->seeUserIsGranted('EDIT', $post);
$I->dontSeeUserIsGranted('DELETE', $post);
RawMessage migrationGrab-then-assert is the workflow:
$I->seeEmailIsSent(2);
$email = $I->grabLastSentEmail(); // declared ?RawMessage since 3.10.0
$I->assertEmailAddressContains($email, 'to', '[email protected]');
$I->assertEmailSubjectContains($email, 'Welcome');
$I->assertEmailHtmlBodyContains($email, 'Confirm your account');
$I->assertEmailAttachmentCount($email, 1);
If you need Email-specific accessors (getTo(), getSubject()), narrow explicitly: assert($email instanceof Email);. Better: migrate to the purpose-built assertEmail* methods, which is why they were added.
$I->seeMessageDispatched(SendInvoiceEmail::class); // count / class-level
$I->seeDispatchedMessageCount(SendInvoiceEmail::class, 1);
$I->seeMessengerTransportContains('async', SendInvoiceEmail::class);
$I->seeMessengerQueueCount(1, 'async');
$I->consumeMessengerMessages('async'); // handler runs inside the test
$I->seeEmailIsSent(1);
This lets you cover the "message dispatched → handler did its job" chain inside one test instead of relying on a worker.
Two cheap regression guards that pay for themselves on listing pages:
$I->seeNumQueriesIsLessThan(10);
$I->dontSeeDuplicateQueries();
$I->seeRequestTimeIsLessThan(500); // ms, profiler-based
Drop these in a dedicated PerformanceCest for your hot routes rather than sprinkling them everywhere — thresholds are brittle if they're not in one place.
runSymfonyConsoleCommand('app:import', ['--dry-run' => true]) still works everywhere. On Symfony 8.1+, runCommand() returns an ExecutionResult with status code, stdout, and stderr separated — assert on it directly:
$result = $I->runCommand('app:import');
$I->assertCommandIsSuccessful();
$I->assertCommandResultEquals("3 rows imported\n");
RegistrationCest, CheckoutCest.see, seeLink, seeElement).tests/Acceptance/_support/Helper/Acceptance.php with domain-level $I->createOrderFor($user)-style actions so Cests read like scenarios.Acceptance (kernel, full stack), a slimmer Smoke (fewer modules) for the long-tail routes, and PHPUnit for units. Symfony-module tests are the slowest tier — treat them accordingly.The kernel reboots between requests. This is the source of most "why did my service lose its state?" confusion. Anything you put in memory in request 1 is gone in request 2 unless it's persisted (persistPermanentService, mockService) or serialized into the session/repository. Don't build tests that depend on in-memory accumulators across amOnPage() calls.
A failed flush() poisons the EntityManager. Once Doctrine closes the EM, every later query in that test throws. Use $I->resetDoctrineManager() (reopens if closed, clears if open) at the start of the follow-up step, or in _before. Also note the 3.10.x fix restoring Doctrine connections after kernel shutdown — if you're pinned below 3.10.1 and see listeners losing security context after a reboot, upgrade.
Profiler-gated assertions. seeEventTriggered, seeOrphanEvent, seeCurrentTemplateIs, and the request-time assertion all read from the profiler. If the profiler isn't enabled in your test env (it often isn't, for speed), these fail with confusing messages rather than "profiler not enabled." Check framework.profiler.collect: true in config/packages/test/framework.yaml first.
Email assertions and redirects. seeEmailIsSent/grabSentEmails rely on the profiler for the request that sent the mail. Once you stopFollowingRedirects(), the module can't navigate onward to inspect that request's profiler data — there are documented restrictions here. Practical rule: read your emails before you continue navigating, or structure the Cest so the mail-sending request is the last one you assert on.
Mailer must be the Symfony Mailer. SwiftMailer support was removed in 2.0.0 and the mailer config option with it. Your test env needs framework.mailer.dsn: 'null://null' (or a test transport) plus framework.test: true; otherwise grabSentEmails() returns an empty collection with no complaint.
Private services and the container error message. Older versions threw a bare ServiceNotFoundException for private services. Use grabContainer() (test container) rather than grabService() for anything private — grabService() still works for public services and gives a friendlier error, but the test container is the correct answer for modern Symfony where almost nothing is public.
app_path is the #1 first-run failure. Default is app (Symfony 2/3 layout). For Symfony 4+ set app_path: 'src' and, if your kernel isn't App\Kernel, set kernel_class explicitly. Symptom: the module can't boot the kernel and every test errors identically.
grabLastSentEmail() return type widened to ?RawMessage. A 3.10.0 change that will break static analysis or Email-specific method calls even though the runtime object is still an Email for mailer emails. Narrow with instanceof or switch to the header/subject assertions.
Version pinning matters more than usual here. 3.x means Codeception 5. The 2.x line is Codeception 4. The 3.9.x → 3.11.x span added a lot of methods (Doctrine, services, security, Messenger, Console) — if your team's docs mention mockService or consumeMessengerMessages and the test fails with "method does not exist," you're on an old lock. Run codecept build after every composer update.
codecept build after any suite/module config change. The Actor's method signatures are generated. A stale Actor is the second-most-common cause of "the method exists in the README but not in my IDE."
Debugging workflow: vendor/bin/codecept run Acceptance --debug shows step timings and the kernel boot/reboot boundaries; --steps shows the resolved selector for DOM assertions. When a form assertion fails, dump the crawler ($I->grabPageSource() or $I->seeInCurrentUrl()) before assuming the form name is wrong — the usual culprit is the wrong name attribute, which is not the same as the form class name.
Extension points, in order of leverage:
tests/_support/Helper/Acceptance.php) — wrap multi-step domain flows; the $I in Cests then reads like business language.FormAssertionsTrait, MailerAssertionsTrait, etc.); if your app has a domain you assert repeatedly (e.g. a billing ledger), write your own trait and enable it via the helper._before/_after hooks in Cests for resetDoctrineManager(), unmockService(), and cache warming — cheaper and more visible than module config.config/packages/test/) — the right place to change mailer DSN, profiler depth, and cache pools; resist doing it in code.When not to use it: for pure JSON API contract testing, module-rest + PhpBrowser is faster and produces clearer failures. For browser behaviour (JS, scroll, focus), you need WebDriver/Playwright. The Symfony module's sweet spot is server-rendered flows where the interesting assertions are inside the app.
How can I help you explore Laravel packages today?