saucebase/laravel-playwright
Integrate Playwright browser testing into Laravel applications with ease. This package provides helpers, configuration, and test utilities for running end-to-end browser tests against your app, streamlining UI testing within your existing PHP workflow.
Install on both sides — the Laravel package and the Playwright helper client:
composer require saucebase/laravel-playwright --dev
npm i -D @saucebase/playwright
php artisan vendor:publish --tag=playwright-config
As of v1.1.0 the package requires Laravel 13. If your app is still on Laravel 11/12, pin composer to ^1.0 and stay there until you upgrade — the JS client is unchanged, so the npm side can track either. Check composer why-not saucebase/laravel-playwright first if the require resolves to an older tag unexpectedly; Composer will silently satisfy an older constraint rather than error.
The package ships a config file (config/playwright.php) that controls which environment the bridge routes are registered in and which middleware/IP guard protects them. The safest default is to auto-register only when APP_ENV=testing and PLAYWRIGHT=true — set the latter in your .env.testing and in the Playwright webServer.env.
Point Playwright at your app with a globalSetup that boots the app, then import the helpers into each test:
// tests/e2e/example.spec.ts
import { test, expect } from '@playwright/test';
import { factories, artisan, time } from '@saucebase/playwright';
test('dashboard greets a returning user', async ({ page, request }) => {
const user = await factories(request).user().create({ name: 'Sam' });
await time(request).travelTo('2026-04-01 09:00');
await page.goto('/dashboard');
await expect(page.getByRole('heading')).toContainText('Sam');
});
First use case to validate the wiring: create a factory user, log in through the login form, and assert the dashboard heading. If that passes, factories, sessions, and the bridge route are all working and everything else is incremental.
Fixture-style setup per test. Create the minimum row set inside the test rather than relying on a shared seeder. This keeps tests parallel-safe and makes failures obvious — a broken test points at the factory call two lines above, not a global DatabaseSeeder change three branches ago.
Compose factories with relationships. Most helpers let you nest related records; if not, chain create() calls and read the foreign key off the parent. Prefer this over hand-writing records through artisan db:seed because you get typed results back in TypeScript.
Use artisan for one-shot app state, not for data. Good targets: artisan('cache:clear'), artisan('queue:work --once'), artisan('migrate:fresh') in globalSetup. Bad targets: seeding users, because the factory API returns handles you can assert on and artisan output is opaque.
Time travel for anything date-sensitive. Billing cycles, trial expiries, scheduled commands, "member since" labels — freeze time at the start of the test, then advance after an action and reload. Prefer relative advancement (time(request).travel(1, 'day')) over hardcoded absolute timestamps so tests don't rot.
Query assertions for server-authoritative state. After a UI action, verify with a query helper that the row actually changed, not just that the toast said "Saved". This catches optimistic-UI bugs that a UI-only assertion hides.
Auth shortcut. For tests not about the login form, most teams expose a small test-only route that Auth::loginUsingId()s a user, then visit /login-as/{id}. If the package ships a session helper, use it — otherwise this is the one place a hand-rolled route is worth maintaining. Keep it behind the same testing-env guard as the bridge.
CI wiring. Run Playwright against php artisan serve --env=testing via webServer in playwright.config.ts, with a fresh SQLite in-memory DB or a throwaway MySQL per worker. Pass the DB name to each worker through process.env so parallel tests don't stomp each other.
Stage the Laravel upgrade separately. Since the package version now tracks the framework major, do the app's Laravel 13 upgrade and the package bump in the same deploy, but land the upgrade PR first and only then move the composer constraint from ^1.0 to ^1.1. Mixing an unresolved framework upgrade with a test-bridge change makes a failing E2E suite ambiguous — you won't know whether it's your app or the bridge.
The bridge is a remote code execution endpoint. Treat the config guard as load-bearing. Verify in production by curling the bridge path and confirming a 404 — never rely on the env check alone. If the package uses a shared secret or signature header, do not commit it; read from process.env in both .env.testing and playwright.config.ts.
Parallel workers and the database. The bridge routes hit the served app, which has its own connection — it does not share the Playwright process's transaction. If you wrap tests in migrate:fresh per worker you need one DB per worker, not one shared DB. Cheap and reliable: database/e2e-${workerIndex}.sqlite, created in globalSetup.
Time travel is process-scoped, not request-scoped. The travelTo call mutates the running app's Carbon instance until reset. If your app forks workers (Horizon, queue listeners), they'll have their own clock. For queued jobs that check now(), either run the queue synchronously in tests (QUEUE_CONNECTION=sync) or travel time before enqueuing and before the worker picks it up.
Factory state resets between test files. test.beforeEach at file scope is where fixtures belong; test.beforeAll shares a request context that may be torn down. If a fixture worked in one file and not the next, it's usually because the bridge session cookie didn't persist across the context boundary.
Artisan exit codes vs output. Most helpers return { stdout, stderr, exitCode }. Assert on exitCode first — Laravel commands often print "done" on stderr while succeeding, and a string match on stdout will pass on warnings and fail on version bumps. Only assert output when the wording is contractual (e.g. your own command's user-facing message).
Debugging the bridge itself. When a factory call hangs, it's almost always the served app failing to boot (missing env var in .env.testing, storage permission, unrun migration). Hit the bridge endpoint directly with curl from the terminal first — if it 500s there, no amount of Playwright debugging will help. Enable APP_DEBUG=true in .env.testing and check storage/logs/laravel.log inside the served app, not the Playwright console. After a framework upgrade, add "did the app boot at all" to that checklist — a clean php artisan about under --env=testing is the fastest signal.
Extension points. Most of these packages expose a hook for registering custom helpers on the client. If you find yourself request.post('/_playwright/...')-ing by hand, wrap it as a custom helper in tests/e2e/support/ so it's typed, versioned with your repo, and mockable in CI.
Version pinning. The package version now moves with the framework major (v1.1.0 = Laravel 13), so treat composer and npm constraints as a pair and commit both lockfiles. On Laravel 13: ^1.1; on Laravel 11/12: ^1.0. Pin the npm client to the matching minor, and check the release page before a composer update that crosses a minor — a single-line "upgrade to Laravel N" entry is a minimum-framework bump, not a patch. Keep the surface small — factories, queries, artisan, time — so a rename in a future release is a one-afternoon migration.
How can I help you explore Laravel packages today?