Page and browser events

Listen to console output, dialogs, requests, responses, and failed requests without turning event handlers into fragile assertions.

Events are live signals from the browser. They cover work that happens outside the direct return value of an action: console messages, dialogs, requests, responses, and failed requests.

Use events for evidence and coordination. Do not turn every event into an assertion. A test should still read like a user flow: act, collect useful signals when needed, and assert the result in the test body.

Mental model: events are not history

An event handler only receives events emitted after it is registered. If the page logs during startup, register the console handler before goto(). If a click opens a dialog, register the dialog handler before the click.

php
use Playwright\Console\ConsoleMessage;
use function Playwright\Testing\expect;

$page->events()->onConsole(static function (ConsoleMessage $message): void {
    printf('[%s] %s'.PHP_EOL, strtoupper($message->type()), $message->text());
});

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

Expected result: console messages emitted during and after navigation are printed.

If a handler sometimes misses the signal, suspect registration order before increasing timeouts.

Safe default: collect, then assert

Keep handlers narrow. A handler should usually do one of three things:

  • record evidence for later assertions;
  • print filtered debug output;
  • perform the required browser response, such as accepting a dialog.

Avoid long waits, navigation, unrelated assertions, and large side effects inside handlers. The failure is easier to understand when assertions stay in the main test flow.

This gives you a clean separation of responsibilities. The handler records what the browser emitted. The test body decides whether that evidence is acceptable. That separation is especially useful when several events fire during one user action.

Console messages

Console events are useful for debugging browser-side JavaScript. Capture type, text, and location, but filter aggressively.

php
use Playwright\Console\ConsoleMessage;

$consoleErrors = [];

$page->events()->onConsole(static function (ConsoleMessage $message) use (&$consoleErrors): void {
    if ('error' !== $message->type()) {
        return;
    }

    $location = $message->location();
    $consoleErrors[] = [
        'text' => $message->text(),
        'url' => $location['url'] ?? null,
    ];
});

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

expect($page->getByRole('heading', ['name' => 'Settings']))->toBeVisible();

if ([] !== $consoleErrors) {
    throw new RuntimeException('Unexpected browser console errors.');
}

Do not dump every console message in normal CI logs. Save broad logs only when diagnosing a failure.

A good policy is to fail only on unexpected error messages for flows where the product promises a clean console. Many apps intentionally log warnings during development builds, so make the filter match the environment and product contract.

Dialogs

JavaScript dialogs block page execution until accepted or dismissed. Register the handler before the action that opens the dialog.

php
use Playwright\Dialog\DialogInterface;
use function Playwright\Testing\expect;

$page->events()->onDialog(static function (DialogInterface $dialog): void {
    if ('confirm' === $dialog->type() && 'Delete this project?' === $dialog->message()) {
        $dialog->accept();

        return;
    }

    $dialog->dismiss();
});

$page->getByRole('button', ['name' => 'Delete project'])->click();

expect($page->getByText('Project deleted'))->toBeVisible();

For prompts, pass the prompt text to accept():

php
$page->events()->onDialog(static function (DialogInterface $dialog): void {
    $dialog->accept('Ada Lovelace');
});

Keep dialog handlers explicit. Automatically accepting every dialog can hide the wrong prompt.

Requests, responses, and failed requests

Network events are passive. They observe traffic without changing it.

php
use Playwright\Network\RequestInterface;
use Playwright\Network\ResponseInterface;

$page->events()->onRequest(static function (RequestInterface $request): void {
    if (str_contains($request->url(), '/api/orders')) {
        printf('> %s %s'.PHP_EOL, $request->method(), $request->url());
    }
});

$page->events()->onResponse(static function (ResponseInterface $response): void {
    if (str_contains($response->url(), '/api/orders')) {
        printf('< %d %s'.PHP_EOL, $response->status(), $response->url());
    }
});

$page->events()->onRequestFailed(static function (RequestInterface $request): void {
    $failure = $request->failure();
    printf('x %s %s'.PHP_EOL, $request->url(), $failure['errorText'] ?? 'failed');
});

Use network events for diagnostics. Use routing when you need to mock, abort, continue, or fulfill requests.

Do not assert every request in every test. That turns a user-flow test into a protocol snapshot. Pick request assertions only when the request is part of the behavior: export started, payment authorized, search endpoint called, or retry shown after a failure.

Choose events, waits, routes, or assertions

Events are one tool in a larger testing model:

Need Prefer Why
know what happened event handler passive evidence
synchronize with one response waitForResponse() tied to a specific backend answer
change what the page receives route() active network control
prove user behavior expect() on the page user-visible result

For example, a checkout test may use a response event while diagnosing CI failures. Once the failure is understood, the permanent test may only need a visible assertion: payment confirmation appears. If the test must force a declined card state, routing or an API fixture may be the right tool instead.

Do not keep diagnostic event logging forever if the final test no longer needs it. Permanent logs should answer a question the test still depends on.

Popups and other event-like flows

Some browser signals have dedicated wait helpers. Prefer those helpers when the flow depends on a specific result.

php
$popup = $page->waitForPopup(function () use ($page): void {
    $page->getByRole('link', ['name' => 'Open invoice'])->click();
});

expect($popup->getByRole('heading', ['name' => 'Invoice']))->toBeVisible();

This keeps the event and the triggering action together. It also prevents a fast popup from appearing before the wait is active.

Do not promise event families that the PHP API does not expose yet. For this site, document the handlers that exist: dialogs, console messages, requests, responses, and failed requests.

Handler design rules

Use these constraints by default:

  • register before the action;
  • filter inside the handler;
  • collect small data, not full bodies or credentials;
  • assert after the action in the main test;
  • clean up routes or context state when a handler changes behavior.

Events are powerful because they reveal invisible browser work. They become fragile when they turn into hidden test logic.

When you need stronger synchronization than a passive event log, choose a dedicated wait. For example, waitForResponse() is clearer than storing every response and polling an array. waitForPopup() is clearer than hoping a generic event will tell you which page to use.

Common mistakes

Registering too late. Event handlers do not replay history.

Failing inside callbacks. The assertion stack becomes harder to understand.

Logging too much. Full request and console logs can hide the one important signal and leak secrets.

Using events instead of assertions. A response event is not proof the user saw the result.

Assuming all upstream Playwright events exist in PHP. Check the PHP API before documenting a handler.

Verification checklist

  • Is the handler registered before the event can fire?
  • Is the handler filtered by type, URL, status, or message?
  • Does it collect only the evidence needed?
  • Is the final assertion in the main test flow?
  • Are secrets excluded from logs?
  • Would a failure point to the product behavior, not just plumbing?

Go next