Evaluating JavaScript

Run code in the page, pass values across the boundary, and avoid replacing locators with DOM scripts.

evaluate() runs JavaScript inside the browser page and returns the result to PHP. Use it when the browser has information that the visible UI does not expose directly: local storage, computed layout, feature flags, custom performance marks, or a diagnostic value from your own application.

The important mental model is the boundary. Your PHP code runs in the PHP process. The evaluated JavaScript runs in the page, with access to window, document, browser APIs, and the application runtime. Values cross that boundary only when Playwright can serialize them. That makes evaluation powerful, but also easy to overuse.

The recommended default is still not evaluation. Prefer locators and expect() when you are testing user-visible behavior. Evaluation is the escape hatch for browser state and controlled diagnostics.

Start with the question you need to answer

Before writing JavaScript, decide what kind of evidence you need:

  • visible behavior belongs to locators and assertions;
  • page navigation belongs to page assertions;
  • request behavior belongs to network tools;
  • browser-only state may belong to evaluate();
  • long-lived diagnostics should usually be reduced to a small serializable value.

That choice keeps tests readable. A future reader should see why JavaScript was necessary.

php
$page->goto('https://app.example.test/dashboard');

$title = $page->evaluate('document.title');
$theme = $page->evaluate('() => window.localStorage.getItem("theme")');

self::assertSame('Dashboard', $title);
self::assertSame('dark', $theme);

Use a function string when the code has statements, parameters, or an explicit return.

php
$metrics = $page->evaluate(<<<'JS'
() => {
    const navigation = performance.getEntriesByType('navigation')[0];

    return {
        type: navigation.type,
        duration: Math.round(navigation.duration),
    };
}
JS);

self::assertSame('navigate', $metrics['type']);
self::assertIsInt($metrics['duration']);

Keep evaluated scripts short. If the script becomes application logic, move that logic into the application, an API endpoint, a fixture, or a small browser helper that you deliberately own.

Pass data as an argument

The second argument to evaluate() is serialized and passed to the JavaScript function. Use it instead of building JavaScript with string concatenation. It handles quotes and newlines correctly and avoids injecting arbitrary text into a script.

php
$user = [
    'id' => 42,
    'name' => 'Ada Lovelace',
];

$page->evaluate(<<<'JS'
(user) => {
    window.localStorage.setItem('current-user', JSON.stringify(user));
}
JS, $user);

$stored = $page->evaluate('() => JSON.parse(window.localStorage.getItem("current-user"))');

self::assertSame(42, $stored['id']);
self::assertSame('Ada Lovelace', $stored['name']);

Avoid this pattern:

php
// Brittle: quotes, newlines, and untrusted data can break the script.
$page->evaluate('window.localStorage.setItem("query", "'.$query.'")');

Prefer this:

php
$page->evaluate(
    '(query) => window.localStorage.setItem("query", query)',
    $query,
);

Evaluate from a locator when the element matters

When the script is about one element, evaluate from the locator. The locator resolves the element first, then your JavaScript receives it.

php
$banner = $page->getByRole('banner');

$height = $banner->evaluate(
    'element => Math.round(element.getBoundingClientRect().height)',
);

self::assertGreaterThan(0, $height);

This is useful for values that are not covered by the assertion API: bounding boxes, scroll positions, custom properties, or diagnostics attached to an element.

Do not use element evaluation to replace normal assertions:

php
// Less useful: reads once and makes waiting your problem.
$text = $page->getByRole('status')->evaluate('element => element.textContent');
self::assertStringContainsString('Saved', $text);

Use a web-first assertion instead:

php
expect($page->getByRole('status'))->toContainText('Saved');
expect($page->getByLabel('Email'))->toHaveValue('ada@example.com');

Assertions retry until the condition is true or the timeout expires. A direct JavaScript read does not.

Return only what PHP can use

Return plain serializable data: strings, numbers, booleans, arrays, and simple objects that become PHP arrays. Do not return DOM nodes, functions, class instances, or large browser objects when a small value would do.

php
$orders = $page->evaluate(<<<'JS'
() => Array.from(document.querySelectorAll('[data-order-id]')).map((node) => ({
    id: node.getAttribute('data-order-id'),
    label: node.textContent.trim(),
}))
JS);

self::assertNotEmpty($orders);
self::assertArrayHasKey('id', $orders[0]);

When you need several related values, return a small object rather than running many independent evaluations. That keeps the browser-side observation atomic: all values come from the same page state.

php
$performanceSummary = $page->evaluate(<<<'JS'
() => {
    const navigation = performance.getEntriesByType('navigation')[0];
    const resources = performance.getEntriesByType('resource');

    return {
        duration: Math.round(navigation.duration),
        resourceCount: resources.length,
    };
}
JS);

self::assertGreaterThan(0, $performanceSummary['duration']);
self::assertGreaterThanOrEqual(1, $performanceSummary['resourceCount']);

Initialize the page before application code runs

Sometimes you need browser state before the page scripts execute: a feature flag, a test hook, a clock seed, or a harmless diagnostic function. Put that on the browser context with addInitScript() before creating or navigating the page that needs it.

php
$context->addInitScript(<<<'JS'
window.__APP_TEST_FLAGS__ = {
    billingPreview: true,
};
JS);

$page = $context->newPage();
$page->goto('https://app.example.test/billing');

expect($page->getByText('Billing preview'))->toBeVisible();

addInitScript() is not a substitute for test data setup. Use it for browser-level hooks, not for hiding application dependencies.

Expose a PHP callback deliberately

BrowserContextInterface::exposeFunction() makes a PHP callback callable from JavaScript. That is useful for narrow bridges such as collecting a value emitted by the page or stubbing a browser callback during a controlled test.

php
$received = [];

$context->exposeFunction('recordClientEvent', static function (array $event) use (&$received): void {
    $received[] = $event;
});

$page = $context->newPage();
$page->goto('https://app.example.test');

$page->evaluate(<<<'JS'
() => window.recordClientEvent({ name: 'hydrated' })
JS);

self::assertSame('hydrated', $received[0]['name']);

Keep exposed functions small. If every test needs a large bridge between PHP and browser code, the application probably needs a clearer test seam.

Common pitfalls

  • Replacing locator assertions with DOM scripts. You lose auto-retry and user-oriented selectors.
  • Returning complex objects. Return the specific data you need.
  • Building JavaScript by concatenating PHP strings. Pass values as the second argument.
  • Hiding application behavior in addInitScript(). Seed the browser only when browser state is the point.
  • Leaving JSHandleInterface objects around in long scripts. Dispose of handles when finished.
  • Debugging a long evaluated script from PHP first. Reduce it to a small browser-console expression, then move it back.

Go next