From JavaScript
Translate Playwright JavaScript patterns into synchronous PHP code.
Converting Playwright JavaScript tests to Playwright PHP is not a search-and-replace exercise. The browser concepts are familiar: browser, context, page, locator, route, request, response, and web-first assertions. The runtime model is different. JavaScript Playwright code is usually async and fixture-driven. Playwright PHP exposes a synchronous API that fits PHPUnit and ordinary PHP scripts.
Keep the test intent. Keep semantic locators where they still read well. Rewrite the surrounding structure in idiomatic PHP.
Start with the runtime model
JavaScript:
import { test, expect } from '@playwright/test';
test('shows the dashboard', async ({ page }) => {
await page.goto('https://example.com/login');
await page.getByLabel('Email').fill('editor@example.com');
});
PHP:
use function Playwright\Testing\expect;
$page->goto('https://example.com/login');
$page->getByLabel('Email')->fill('editor@example.com');
There is no async, no await, and no promise to return. Each PHP call blocks until Playwright responds or the configured timeout is reached. This makes simple scripts easy to read, but it also means you must review JavaScript patterns that relied on Promise.all() or event ordering.
Translate the test shell, not just the calls
Playwright Test gives JavaScript users fixtures such as page, browser, context, request, and per-test configuration. In PHP, those concerns belong in PHPUnit lifecycle methods, helper methods, or explicit setup code.
<?php
declare(strict_types=1);
namespace Tests\E2E;
use PHPUnit\Framework\TestCase;
use Playwright\Testing\PlaywrightTestCaseTrait;
use function Playwright\Testing\expect;
final class DashboardTest extends TestCase
{
use PlaywrightTestCaseTrait;
protected function setUp(): void
{
parent::setUp();
$this->setUpPlaywright();
}
protected function tearDown(): void
{
$this->tearDownPlaywright();
parent::tearDown();
}
public function testEditorCanOpenDashboard(): void
{
$this->page->goto('https://example.com/dashboard');
expect($this->page->getByRole('heading', ['name' => 'Dashboard']))->toBeVisible();
}
}
If you are converting a one-off script, creating the browser manually is fine. If you are converting a suite, centralize lifecycle and defaults. Do not scatter browser startup code across every test class.
Convert common calls mechanically
Many calls translate directly once you remove await and switch to PHP variables:
| JavaScript | PHP |
|---|---|
await page.goto(url) |
$page->goto($url) |
await page.getByLabel('Email').fill(value) |
$page->getByLabel('Email')->fill($value) |
await page.getByRole('button', { name: 'Save' }).click() |
$page->getByRole('button', ['name' => 'Save'])->click() |
await locator.press('Enter') |
$locator->press('Enter') |
await locator.selectOption('fr') |
$locator->selectOption('fr') |
await page.screenshot({ path }) |
$page->screenshot($path) |
await page.setViewportSize({ width, height }) |
$page->setViewportSize($width, $height) |
Prefer locators over page-level selector shortcuts during conversion. Locators re-resolve and auto-wait, and they produce tests that describe user intent:
$page->getByLabel('Search')->fill('invoice');
$page->getByRole('button', ['name' => 'Search'])->click();
Convert options into arrays or builders
JavaScript option objects become PHP associative arrays:
await page.goto('/reports', { waitUntil: 'domcontentloaded', timeout: 15000 });
$page->goto('/reports', [
'waitUntil' => 'domcontentloaded',
'timeout' => 15000,
]);
For context options used in several places, use a helper or BrowserContextBuilder:
use Playwright\Browser\BrowserContextBuilder;
$contextOptions = BrowserContextBuilder::create()
->withViewport(1280, 720)
->withLocale('en-US')
->withTimezoneId('UTC')
->withStorageState(__DIR__.'/.auth/editor.json')
->toArray();
$context = $browser->newContext($contextOptions);
Use builders to make intent readable, not to hide important state. A test reviewer should be able to see when a context is authenticated, mobile, localized, or granted permissions.
Convert callbacks into closures
JavaScript callbacks become PHP closures. Capture PHP variables with use (...) when needed.
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Ada' }]),
});
});
$users = [['id' => 1, 'name' => 'Ada']];
$page->route('**/api/users', function ($route) use ($users): void {
$route->fulfill([
'status' => 200,
'contentType' => 'application/json',
'body' => json_encode($users, JSON_THROW_ON_ERROR),
]);
});
Register routes before goto() or before the user action that triggers the request. This rule is the same as JavaScript, but conversion often breaks it because code is reorganized into helpers.
Keep web-first assertions
The biggest quality regression in conversions is replacing auto-waiting assertions with immediate value reads.
Good:
expect($page->getByText('Saved'))->toBeVisible();
expect($page)->toHaveURL('https://app.example.test/settings');
Risky:
self::assertSame('Saved', $page->locator('.toast')->textContent());
The first version waits for the UI to reach the expected state. The second reads once and fails if the app is still rendering. Use immediate reads only when the value is already known to be stable.
Review async event patterns manually
The JavaScript pattern below is common:
const [response] = await Promise.all([
page.waitForResponse('**/api/orders'),
page.getByRole('button', { name: 'Load orders' }).click(),
]);
Do not blindly move the wait after the click. If the response is fast, you can miss the event. In PHP, prefer a visible user result when that result is what matters:
$page->getByRole('button', ['name' => 'Load orders'])->click();
expect($page->getByText('Order #1001'))->toBeVisible();
When the response itself is the requirement, use the PHP wait API deliberately and keep the scenario small. The public page API includes waitForResponse() and waitForPopup(callable $action, array $options = []); popup flows should keep the click inside the callable:
$popup = $page->waitForPopup(function () use ($page): void {
$page->getByRole('link', ['name' => 'Open invoice'])->click();
});
expect($popup)->toHaveURL('https://app.example.test/invoice/1001');
Downloads, file choosers, and lower-level events need extra care because the PHP surface is not a one-to-one copy of page.waitForEvent() from JavaScript. Check the installed API page or interface before translating those patterns.
Keep JavaScript inside evaluate
evaluate() still runs JavaScript in the browser. The outer code is PHP; the evaluated body remains JavaScript.
$count = $page->evaluate(<<<'JS'
() => document.querySelectorAll('[data-row]').length
JS);
Pass data as an argument instead of interpolating when values come from PHP:
$theme = 'dark';
$page->evaluate(<<<'JS'
theme => window.localStorage.setItem('theme', theme)
JS, $theme);
This avoids quoting mistakes and keeps user-controlled values out of executable JavaScript strings.
Watch naming and return values
Some method shapes differ from JavaScript. setViewportSize() receives width and height as positional integers. screenshot() can receive the path as the first argument. Locator::screenshot() returns ?string, while Page::screenshot() returns a string. Do not assume every JavaScript return value is mirrored exactly.
PHP also has stricter namespace and import requirements. Import expect() explicitly:
use function Playwright\Testing\expect;
Prefer strict types and named test methods. A converted test should look like PHP code maintained by a PHP team, not TypeScript squeezed into PHP syntax.
Verify parity instead of guessing
Upstream documentation is useful for understanding Playwright concepts, but it is not the PHP reference. Before translating an unfamiliar method or option, check:
- whether the class exists in the installed PHP package;
- the PHP method name and parameter shape;
- whether options use an associative array or a builder;
- the return type;
- whether the feature lives in the core library or a companion package.
Use the API parity report for coverage and the API reference for exact PHP signatures. If the two disagree with an installed version, the installed package is the runtime truth.
Conversion checklist
- Remove
async,await, promises, and Playwright Test fixtures. - Move setup into PHPUnit lifecycle methods or explicit helpers.
- Convert option objects to associative arrays.
- Use
BrowserContextBuilderwhen shared context setup benefits from names. - Convert arrow callbacks to closures and capture variables with
use. - Keep semantic locators where possible.
- Keep web-first assertions instead of immediate reads.
- Manually review every popup, network wait, download, file chooser, and event pattern.
- Run the converted test before deleting or ignoring the JavaScript original.
Go next
- Browser: PHP object model and synchronous calls.
- Testing with PHPUnit: suite structure and lifecycle.
- Assertions: web-first assertions.
- Network: routing and response waits.
- API parity: compare PHP coverage with upstream Playwright.