Authentication and state
Log in through the UI, or save the authentication state once and reuse it.
Authentication is part of the test environment, except when authentication is the behavior under test. Treating every scenario as "first log in, then test the feature" makes suites slower and more fragile. Login forms are often protected by rate limits, CAPTCHAs, email links, one-time codes, third-party redirects, and security rules that intentionally change over time.
The practical model is simple: test the login journey directly in a small number of tests, then give the rest of the suite an already authenticated browser context. In Playwright PHP, that usually means saving storage state after a successful login and loading that state when a test needs a known user.
Choose the authentication strategy
Use the UI when the login experience is the product:
- anonymous redirect to the login page;
- form validation;
- password reset;
- logout;
- account lockout;
- identity-provider redirect behavior.
Use saved state when authentication is only setup:
- dashboard tests;
- role-specific workflows;
- checkout as a known customer;
- admin screens;
- regression tests that only need "a logged-in user".
This distinction matters. If a billing test fails because the login provider changed its markup, the failure says nothing about billing. Keeping login setup separate makes failures easier to read and reduces pressure on auth services.
Test the login flow once
A login test should act like a user and assert the state that proves login completed. Do not save storage state until after the application has reached a stable authenticated page.
use function Playwright\Testing\expect;
$page->goto('https://app.example.com/login');
$page->getByLabel('Email')->fill('editor@example.com');
$page->getByLabel('Password')->fill(getenv('TEST_USER_PASSWORD') ?: 'secret');
$page->getByRole('button', ['name' => 'Sign in'])->click();
$page->waitForURL('**/dashboard', ['timeout' => 15000]);
expect($page->getByRole('heading', ['name' => 'Dashboard']))->toBeVisible();
Prefer stable, semantic locators. Login screens are sensitive to copy and accessibility regressions, so getByLabel() and getByRole() usually give better feedback than CSS selectors.
Save storage state for reuse
Storage state captures cookies and local storage for a context. Create a dedicated setup script or suite setup step that logs in and writes the state file.
<?php
require __DIR__.'/vendor/autoload.php';
use Playwright\Playwright;
$statePath = __DIR__.'/.auth/editor.json';
$context = Playwright::chromium(['headless' => true]);
$page = $context->newPage();
$page->goto('https://app.example.com/login');
$page->getByLabel('Email')->fill(getenv('TEST_USER_EMAIL') ?: 'editor@example.com');
$page->getByLabel('Password')->fill(getenv('TEST_USER_PASSWORD') ?: 'secret');
$page->getByRole('button', ['name' => 'Sign in'])->click();
$page->waitForURL('**/dashboard', ['timeout' => 15000]);
$context->saveStorageState($statePath);
$context->close();
Commit the script, not the generated state file. A state file can contain session cookies and local storage tokens. Add .auth/ to .gitignore unless the files are generated from fake local accounts and intentionally safe to share.
The same context also exposes storageState($path), getStorageState(), setStorageState(), and loadStorageState($path). Use the file helpers for normal test setup; use the object helpers only when you need to inspect or transform the state.
Load state into isolated contexts
Reuse the saved file, not a live browser context. Each test should still get its own context so cookies, local storage, permissions, routes, and downloads do not leak into the next scenario.
use Playwright\PlaywrightFactory;
$playwright = PlaywrightFactory::create();
$browser = $playwright->chromium()->withHeadless()->launch();
$context = $browser->newContext([
'storageState' => __DIR__.'/.auth/editor.json',
]);
$page = $context->newPage();
$page->goto('https://app.example.com/dashboard');
Keep one state file per meaningful role:
.auth/admin.json;.auth/editor.json;.auth/customer.json;.auth/anonymous.jsononly if your app stores pre-consent or feature-flag state.
Role names should match product language. If the application distinguishes "owner" and "billing manager", give each its own account and state file. Authorization tests become clearer because the fixture name already explains the expected permissions.
Cookies and header-based auth
Some applications authenticate with a simple cookie, especially local test apps. You can add cookies directly to a context:
$context->addCookies([
[
'name' => 'session',
'value' => $testSessionId,
'domain' => 'app.example.com',
'path' => '/',
'httpOnly' => true,
'secure' => true,
'sameSite' => 'Lax',
],
]);
This is useful when your backend test fixture can mint a session without going through the UI. Make the fixture responsible for creating a real server-side session; the browser test should not know how session tokens are encoded.
Header-based auth is more delicate. Apply a header to the context when every page in the session needs it:
$context->setExtraHTTPHeaders([
'Authorization' => 'Bearer '.$testAccessToken,
]);
Use $page->setExtraHTTPHeaders() when the header belongs to only one page. Centralize either form in test setup so its scope is visible. Prefer storage state, cookies, or backend fixtures when they represent the application's real session model more accurately.
Never print bearer tokens, cookies, or state file contents in CI logs. If you need to debug, log whether a secret is present, the current URL, and the account role. Do not log the value.
Handle expiry and session drift
Saved state is a cache, not a permanent identity. It can expire because cookies have a lifetime, the server invalidates sessions, the account password changes, the identity provider rotates tokens, or the application deploys a new session format.
A robust suite makes this visible:
$page->goto('https://app.example.com/dashboard');
if (str_contains($page->url(), '/login')) {
throw new RuntimeException('Authenticated state expired; rerun the auth setup script.');
}
This guard is intentionally blunt. A feature test should not silently perform a login flow because the setup state is stale. Regenerate the state in a setup job or a local command such as composer auth:refresh.
For CI, run authentication setup before the browser test job. If the setup fails, fail the pipeline there. If a later test lands on /login, capture a screenshot or trace, but keep secrets out of artifacts.
Common mistakes
Saving state too early is the most common failure. Wait for a post-login URL or a visible authenticated element before saving.
Sharing one context between tests is the second. Reuse state files, not the live context. A shared context carries unseen changes: feature flags, local storage, route handlers, permissions, and open pages.
Mixing roles in one account hides authorization bugs. Use separate accounts for separate roles, even if they share the same password in a local environment.
Using production credentials in CI is not acceptable. Browser tests need dedicated test accounts with limited permissions and resettable data.
Finally, do not over-optimize auth setup before the suite is readable. A clear login setup script that takes ten seconds is better than an opaque token-minting shortcut nobody trusts.
Go next
- Testing with PHPUnit: where to create auth setup in a suite.
- Continuous integration: secret handling and pipeline order.
- Network: routing requests and inspecting auth failures.
- Debugging: collect evidence without leaking secrets.