playwright-php/playwright-symfony
Symfony integration for playwright-php, giving Symfony apps and test suites a clean way to drive real browsers with Playwright for end-to-end testing, scraping, and UI automation.
playwright-php/playwright-symfony is a bridge, not a test framework. It gives you a BrowserKit-compatible client backed by a real Playwright browser, plus DomCrawler/WebTestCase glue so that Symfony's existing $client->request() + $crawler->filter() style keeps working while the page actually executes JavaScript. That framing matters for a Laravel developer: you are evaluating whether the transport (real browser) is worth bolting onto a stack whose test case class you cannot extend.
Minimum steps
composer require --dev playwright-php/playwright-symfony (check the license file first — see Gotchas).playwright npm package, then npx playwright install chromium (or --with-deps in CI). The PHP side is a thin client over a Node server; nothing runs without it.playwright-php/playwright client also supports auto-launching, which is what you want locally.framework.test: true and session.storage.factory.mock_file are prerequisites for any BrowserKit client — if those aren't set, the client will appear to work and then lose state on redirect.Where to look first (in this order)
README.md — for the client FQCN and the server bootstrap command. This is the whole API surface; there is no extensive docs site.src/ for the AbstractBrowser subclass (the seam you'll actually touch) and the WebTestCase subclass (the part that is Symfony-specific and therefore not reusable in Laravel).playwright-php/playwright repo for the fluent page API (goto, locator, click, fill, content, waitForLoadState, event listeners). That's where 90% of your day-to-day calls will live.composer.json for the Symfony version constraints and the Playwright client version it pins — version skew against your Node playwright is the #1 source of "it just hangs."First use case
Pick exactly one flow that Laravel's $this->get() cannot see: a Livewire/Alpine login form that renders its fields client-side, an Inertia page whose content is hydrated after boot, or a checkout step gated behind a JS validation. Assert on a Crawler built from $page->content() after an explicit wait, not on the raw response body. If that one flow works, expand; if it doesn't, you've spent an afternoon instead of a sprint.
For Laravel specifically, do not try to extend the package's WebTestCase — it descends from Symfony\Bundle\FrameworkBundle\Test\WebTestCase and expects a Symfony kernel. Either use the client class standalone inside an Illuminate\Foundation\Testing\TestCase, or skip the bridge entirely and call playwright-php/playwright directly; the bridge's value in a Laravel app is roughly "DomCrawler-shaped return values," which you can get with three lines of your own code.
Progressive enhancement ladder, not a replacement. Keep the fast HTTP test suite ($this->get(), assertSee, assertDatabaseHas) as the default. Reserve the browser for the two or three flows where rendering is the thing under test. A browser-backed suite is ~50–200x slower per request; converting a 600-test suite is how this package gets ripped out again in a month.
BrowserKit drop-in preserves your assertions. The point of the AbstractBrowser implementation is that followRedirect(), selectLink(), selectButton(), filter() and Crawler all behave as before. So the migration pattern is: same test body, different client instantiation. Write new assertions in DomCrawler style from day one so they survive a swap back to the in-process client if the package is abandoned.
Page objects over the raw client. Wrap each screen in a small class with open(), submitLogin(string $email, string $password): Crawler, and accessors that return Crawler. This isolates the Playwright calls to a handful of files and makes the locator churn (which is constant) a one-file diff. It also gives you the natural place to hang retries and screenshots.
State setup is the hard part. A real browser is a separate process talking to a real HTTP server; Laravel's actingAs(), withSession(), and RefreshDatabase transactions do not cross that boundary. Practical pattern: a test-only login route or a factory-seeded user plus a real form fill, and a database strategy (migrate:fresh once per suite, truncate between tests) that the server process honours — env vars in phpunit.xml only reach the server if it inherits them at launch.
One browser, one context, many pages. Launch in setUpBeforeClass, create a fresh BrowserContext per test (cheap), close everything in tearDownAfterClass. Launching a browser per test is the single biggest avoidable cost.
Failure artefacts by default. Hook tearDown to capture a screenshot, the page HTML, and a trace on failure; dump them into storage/framework/testing/screenshots/. Register page->on('console') and page->on('requestfailed') listeners and fail the test on uncaught JS errors — this catches the class of bug that string assertions on HTML never will.
CI wiring. Cache ~/.cache/ms-playwright, run npx playwright install --with-deps chromium on miss, run headless, and set a per-test timeout (30s) and a locator timeout (5s) so a missing selector fails fast instead of burning the job. If you use artisan serve or Octane as the target, start it in the job setup and health-check the port before the suite runs.
Assertion bridge. new Crawler($page->content()) after waitForLoadState('domcontentloaded') plus an explicit locator(...)->waitFor() gives you DomCrawler semantics on rendered markup. This is the cleanest way to mix "real browser" with "readable assertions."
Choose one browser stack. If you already run Dusk or Pest's browser plugin, this package is a second stack with a second driver, a second CI install step, and a second failure mode. Adopt it only if you specifically want BrowserKit-style assertions over a real browser, or you're maintaining a Symfony app alongside the Laravel one and want a shared idiom.
License is NOASSERTION. GitHub could not classify it. That is not "probably MIT" — it means the repo's licensing terms are unclear or nonstandard. Before this goes anywhere near a commercial codebase, read LICENSE and get a human to sign off. With 41 stars and zero dependents, assume you may need to vendor and maintain a fork.
It is a Symfony package. There is no service provider, no facade, no RefreshDatabase awareness, and the WebTestCase is not compatible with Illuminate\Foundation\Testing\TestCase. Anything Laravel-shaped — auth, factories, session, config — you wire by hand. Budget for that in the estimate; the bridge itself is small, the integration is not.
Kernel interception vs. a real socket. BrowserKit normally calls the kernel directly, so no server is needed. A real browser cannot do that; it needs a listening port. That single fact cascades into: a second PHP process, a second env/config load, a second database connection, and log output you have to hunt for. Most "the browser sees stale data" bugs are this.
actingAs() is invisible to the browser. The browser holds its own cookie jar. Either drive the real login form or inject the session + XSRF-TOKEN cookies into the context — and if you inject, remember both halves of the pair or Laravel will 419 you.
Don't use networkidle. It's deprecated in Playwright and unreliable on apps with polling, websockets, or analytics beacons. Wait on the specific element or waitForResponse for the specific endpoint. Same for sleep() — every sleep() you add is a test that will flake differently on CI.
Version skew is the top failure mode. The PHP client and the Node playwright package must match; pin both in composer.json and package.json, and bump them together. Symptom of mismatch is usually a hang or a protocol error on launch(), not a clear message.
Browser binaries are large. Don't install them on every CI run; cache ~/.cache/ms-playwright keyed on the Playwright version. Also check the runner has the shared libs (--with-deps handles it on Ubuntu, nothing handles it on Alpine).
Debug loop: headed + slowMo + trace. PWDEBUG=1 and slowMo: 250 locally, and trace: 'retain-on-failure' in CI. Reading a trace beats adding var_dump to a test that talks to a subprocess you can't step through.
Release cadence is the risk you're underwriting. The listed release date is ahead of today, which suggests either a pre-release tag or metadata you shouldn't trust blindly — verify the actual tags on Packagist before you plan around them. With one maintainer and no dependents, write your tests so the package is swappable: keep Playwright calls behind page objects and assert through Crawler, so replacing the transport is a contained change.
Extension surface is the AbstractBrowser subclass. If you need request logging, retry-on-timeout, or header injection, decorate that class rather than patching the test case. If it doesn't expose enough, fork — the surface is small enough that a fork is a day, not a quarter, which is the one genuinely reassuring thing about the package's size.
How can I help you explore Laravel packages today?