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:

typescript
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:

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
<?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:

php
$page->getByLabel('Search')->fill('invoice');
$page->getByRole('button', ['name' => 'Search'])->click();

Convert options into arrays or builders

JavaScript option objects become PHP associative arrays:

typescript
await page.goto('/reports', { waitUntil: 'domcontentloaded', timeout: 15000 });
php
$page->goto('/reports', [
    'waitUntil' => 'domcontentloaded',
    'timeout' => 15000,
]);

For context options used in several places, use a helper or BrowserContextBuilder:

php
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.

typescript
await page.route('**/api/users', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Ada' }]),
  });
});
php
$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:

php
expect($page->getByText('Saved'))->toBeVisible();
expect($page)->toHaveURL('https://app.example.test/settings');

Risky:

php
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:

typescript
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:

php
$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:

php
$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.

php
$count = $page->evaluate(<<<'JS'
() => document.querySelectorAll('[data-row]').length
JS);

Pass data as an argument instead of interpolating when values come from PHP:

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:

php
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 BrowserContextBuilder when 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