valcuandrei/pest-e2e

A Laravel-first package that runs JS-owned E2E and component tests from Pest without introducing a PHP browser DSL.
43 23
Install
composer require valcuandrei/pest-e2e
Latest Version:v1.3.0
PHP:^8.4
Laravel:13
License:MIT
Last Updated:Oct 13, 2026
Links: GitHub  ·  Packagist
Maintainer: webcityro

pestE2E

Write Pest tests. Run real browser E2E tests. Keep everything inside your Laravel test suite.

pestE2E hero

pestE2E is a Laravel-first bridge between Pest and JavaScript-native browser testing.

Laravel owns your test intent, state, authentication, fixtures, and assertions. JavaScript owns the browser. pestE2E connects the two and maps structured browser results back into normal Pest output.

No PHP browser DSL. No Playwright wrapper. No separate frontend-only testing workflow.


How It Works

  1. Write a Pest test in PHP.
  2. Pass authentication, parameters, and context to your JavaScript test runner.
  3. Run real browser tests with Playwright.
  4. Report structured results back into Pest.
Pest test → Playwright/browser → JSON report → Pest output

Example

Pest

use App\Models\User;

test('that a user can update their profile', function () {
    $user = User::factory()->create();

    e2e('frontend')
        ->actingAs($user)
        ->withParams([
            'name' => 'Test User',
            'email' => 'test@example.com',
        ])
        ->runTest('UserProfile can update their profile');

    expect($user->fresh()->name)->toBe('Test User');
    expect($user->fresh()->email)->toBe('test@example.com');
});

Playwright

import { test, expect } from '@playwright/test';
import { readParams } from '../pest-e2e/core.mjs';

test('UserProfile can update their profile', async ({ page }) => {
    const { name, email } = await readParams();

    await page.goto('/settings/profile');
    await page.locator('#name').fill(name);
    await page.locator('#email').fill(email);
    await page.getByTestId('update-profile-button').click();

    await expect(page.getByText('Saved.')).toBeVisible();
});

Laravel controls state and authentication. JavaScript controls the browser.


Why pestE2E Exists

Pest is excellent for Laravel tests. Playwright is excellent for browser tests.

pestE2E lets you use both without pretending one is the other.

It is designed for applications where the browser matters:

  • Vue, React, Inertia, Livewire, or SPA interfaces
  • Drag-and-drop builders
  • Layout editors
  • Resizable UI systems
  • CSS box-model assertions
  • Transform-based positioning
  • DOM measurement and layout calculations
  • Frontend state inspection

pestE2E does not hide Playwright behind PHP methods. It lets Laravel orchestrate your browser suite while JavaScript keeps full ownership of browser automation.


Key Features

  • Pest-native orchestration for JavaScript E2E tests
  • Playwright by default
  • Runner agnostic through worker contracts
  • Laravel authentication using one-time auth tickets
  • Managed Laravel testing server
  • Isolated testing environment
  • Parallel-safe worker ports and auth ticket keys
  • JS test filtering via only() and runTest()
  • Structured JSON reporting contract: pest-e2e.v1
  • Agent / PAO JSON output for AI agents and CI parsers
  • Headed and debug mode support
  • Laravel Sail WSLg headed mode support
  • PHPStan-compliant internals

What This Is Not

pestE2E is not:

  • a browser abstraction
  • a PHP wrapper around Playwright
  • Dusk
  • Selenium
  • a PHP API for visit(), click(), or type()

All browser logic stays in JavaScript.


Status

Stable v1

The public PHP API, authentication contract, and JSON report schema are locked. Internal runner adapters may evolve.


Installation

Install the package:

composer require valcuandrei/pest-e2e --dev

Then run the installer:

php artisan pest-e2e:install

For unattended setup:

php artisan pest-e2e:install --yes

Alias:

php artisan pest-e2e:install --unattended

The installer can:

  • update your pest.php to include E2ETestCase
  • publish config/pest-e2e.php
  • publish the base E2E test case
  • publish the JS harness
  • publish the Playwright integration
  • install @playwright/test
  • download Playwright browser binaries with playwright install
  • create or update .env.testing with parallel-safe E2E overrides
  • create database/testing.sqlite for projects that intentionally use SQLite
  • configure phpunit.xml so .env.testing controls DB/cache values
  • ensure phpunit.xml defines a Browser testsuite for tests/Browser
  • merge the WSLg headed-mode block into Laravel Sail when Sail is detected

Each step is skipped if already done.


Installer Options

Option Description
--yes Answer yes to all questions and perform full setup
--unattended Alias for --yes
--no Answer no to all questions
--force Overwrite existing files when publishing
--update-pest Update Pest config to include E2ETestCase
--setup-env-testing Create .env.testing with parallel-safe E2E overrides
--update-testing-env Patch an existing .env.testing with parallel-safe session/cache/queue settings
--setup-testing-database Create database/testing.sqlite for projects that intentionally use SQLite
--configure-phpunit Comment out DB/cache env values in phpunit.xml so .env.testing controls them
--sail-wslg-headed Merge WSLg display/volume settings into the Sail laravel.test service
--add-csrf-exclusion Add the pestE2E auth route to CSRF exclusions
--publish-config Publish config
--publish-base-test-case Publish E2ETestCase
--publish-js-harness Publish JS harness
--publish-js-playwright Publish Playwright adapter
--publish-browser-tests Publish browser tests
--publish-playwright-tests Publish Playwright tests
--install-playwright Install @playwright/test and run playwright install
--package-manager= Force the package manager used for E2E runs: npm, yarn, pnpm, or bun

When publishing E2ETestCase, the installer resolves the package manager from tools found on your PATH in this priority order:

pnpm → yarn → bun → npm

If multiple tools are available, interactive installs prompt you to choose. With --yes / --unattended, the installer prefers a manager matching an existing lockfile, then falls back to the priority order. If none are found on PATH, it uses lockfile-only detection, then falls back to npm.

Pass --package-manager=pnpm, --package-manager=yarn, --package-manager=bun, or --package-manager=npm to force the value.


Testing Environment

pestE2E starts a managed Laravel server using:

--env=testing

If .env.testing exists, Laravel loads it automatically.

The installer can create this file with --setup-env-testing or update an existing one with --update-testing-env.

Recommended .env.testing values for Sail-compatible MySQL:

APP_ENV=testing
APP_URL=http://127.0.0.1

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=testing
DB_USERNAME=sail
DB_PASSWORD=password

SESSION_DRIVER=array
CACHE_STORE=array
QUEUE_CONNECTION=sync

PEST_E2E_AUTH_ROUTE_ENABLED=true

These values ensure:

  • your development database is not modified
  • auth routes are enabled only during testing
  • the Pest process and managed server use the same database
  • feature tests and browser tests do not share database-backed session/cache/queue state across workers

Your phpunit.xml should not override DB_CONNECTION, DB_DATABASE, CACHE_STORE, or SESSION_DRIVER. Let .env.testing control them.

The installer can comment those overrides out for you with:

php artisan pest-e2e:install --configure-phpunit

Parallel Testing

Browser tests that call e2e()->...->run() can run with Pest / Laravel parallel testing:

php artisan test --parallel --processes=4

For first-time database bootstrapping, run:

php artisan test --parallel --recreate-databases

Use a real test database such as MySQL or PostgreSQL. Laravel parallel testing creates one database per worker:

testing_test_1
testing_test_2
testing_test_3
testing_test_4

SQLite is not recommended for parallel browser testing because it does not provide the same per-worker database isolation model.

Each Pest worker gets:

  • its own managed Laravel server
  • a dedicated HTTP port
  • APP_URL / baseUrl matching that port
  • worker-scoped auth ticket cache keys
  • the same DB_* / CACHE_PREFIX env as the PHP worker

Default port formula when server.parallel_port_offset is enabled:

server.port + TEST_TOKEN

Example with base port 8800:

worker 1 → 8801
worker 4 → 8804

Serial runs keep using an ephemeral free port.

Configure the base port with:

PEST_E2E_SERVER_PORT=8800

Legacy env is also supported:

PEST_E2E_PARALLEL_BASE_PORT=8800

You can also configure this in config/pest-e2e.php under server.port.


Quick Start

Configure a target in tests/E2ETestCase.php inside setUp():

e2e()->target('frontend', fn ($p) => $p
    ->dir('resources/js/e2e')
    ->env(['APP_URL' => 'http://localhost'])
    ->params(['baseUrl' => 'http://localhost'])
);

Register targets in your base E2E test case, not inside individual test functions.

Run all tests for a target:

e2e('frontend')->run();

Run a specific JS test:

e2e('frontend')->runTest('UserProfile can update their profile');

Pass parameters:

e2e('frontend')
    ->withParams(['name' => 'Test User'])
    ->runTest('UserProfile can update their profile');

Authenticate as a Laravel user:

e2e('frontend')
    ->actingAs($user)
    ->runTest('Dashboard loads');

actingAs() and loginAs() are both supported.


Managed Testing Server

When you call:

e2e('frontend')->run();

pestE2E automatically:

  1. boots a temporary Laravel HTTP server
  2. forces it into APP_ENV=testing
  3. binds it to 127.0.0.1
  4. chooses a free port for serial runs, or a worker-specific port for parallel runs
  5. executes your JS runner against that server
  6. collects the JSON report
  7. shuts the server down

No manual php artisan serve required.


Running Tests

Local:

php artisan test

Laravel Sail:

sail artisan test

Parallel:

php artisan test --parallel --processes=4

Successful E2E detail output is shown in normal test runs.

In --compact and --parallel runs, passed E2E details are suppressed so Pest output stays readable. Failed E2E runs still print their details.


Agent / PAO Output

For AI agents and CI parsers, enable compact JSON output. This emits one line per e2e()->run():

PEST_E2E_AGENT_OUTPUT=1 php artisan test ./tests/Browser

You can also enable it with the CLI flag:

php artisan test ./tests/Browser --pest-e2e-agent-output
php artisan test ./tests/Browser --parallel --pest-e2e-agent-output

Agent output is also auto-detected when laravel/agent-detector is installed or common agent env vars are set, such as:

CURSOR_AGENT

Configure it with:

PEST_E2E_AGENT_OUTPUT=1
PAO_FORCE=1

Or in config/pest-e2e.php:

'agent_output' => true,

Disable it with:

PEST_E2E_AGENT_OUTPUT_DISABLE=1
PAO_DISABLE=1

In agent mode, human-readable Pest output is suppressed. Failed runs include:

  • php_test
  • failures
  • JS test name
  • JS file
  • message
  • stack
  • report_dir

See .docs/API.md for the full JSON contract.


Debug & Headed Mode

php artisan test --browse
php artisan test --debug
php artisan test --run-using=yarn
  • --browse / --headed runs the browser in headed mode
  • --debug enables debug mode and implies headed mode
  • --run-using=npm|yarn|pnpm|bun uses a specific package manager for E2E runs

The default package manager is set in E2ETestCase::$e2ePackageManager during install.


Timing Instrumentation

Enable baseline timing markers:

PEST_E2E_TIMING=true

Markers are emitted to stderr with this prefix:

[pest-e2e:timing]

Each marker is a JSON payload with:

  • phase
  • atMs
  • optional durationMs

Node Compile Cache

pestE2E runs your specs with the real Playwright Test CLI, one fresh playwright test process per run. That keeps every native feature and every run isolated, and it means each run loads Playwright's own JavaScript again, in the CLI process and in the worker it starts.

On Node 22.1 and newer, pestE2E lets Node reuse the compiled form of those modules between runs by setting NODE_COMPILE_CACHE for the Playwright process. Nothing else about the launch changes: same command, same arguments, same process model, same results.

Defaults

  • Enabled automatically when the node on your PATH is 22.1 or newer.
  • On older Node versions the variable is not set and Playwright starts exactly as before.

Precedence

  1. A NODE_COMPILE_CACHE you define yourself always wins and is never changed. That applies to a value in your shell or .env.testing and to one passed with ->env([...]) on a target. The same goes for NODE_DISABLE_COMPILE_CACHE.
  2. Otherwise node_compile_cache.enabled decides whether pestE2E sets one.

To turn the automatic behaviour off:

PEST_E2E_NODE_COMPILE_CACHE=false

Turning it off never removes or overrides a NODE_COMPILE_CACHE you set yourself.

Location and lifecycle

  • The cache lives in <system temp dir>/pest-e2e-node-compile-cache-<uid>-<project hash>. The hash comes from the real path of the project root, so every Pest parallel worker of a project shares one cache and unrelated projects never do.
  • The directory is created once with owner-only permissions. pestE2E does not use a directory that is a symlink, belongs to another user, or is writable by others; in that case it prints one line to stderr and runs without the cache.
  • Node manages the contents and keeps a separate subdirectory per Node version. Nothing is pruned per test. It is safe to delete the directory at any time; it is rebuilt on the next run.
  • The cache is an optimisation only. Node ignores a cache directory it cannot use, and a run never fails because of it.

What to expect

The saving is a fixed amount of Node start-up work per run, so how much it matters depends on everything else a run does. These are measurements from one project, not guarantees:

Conditions (Node 24.16, Playwright 1.63, 12 logical CPUs) Cache off Cache on
Isolated spec, no application: process start to first test callback 1.61 s 1.35 s
17 real browser tests, serial: Playwright start to first test start (median of 51 runs) 7.00 s 6.77 s
Same tests, serial: whole Playwright process (median of 51) 9.34 s 8.95 s
Same tests, 4 parallel Pest workers: Playwright start to first test start (median of 51) 10.35 s 9.60 s
Same tests, 4 parallel Pest workers: whole Playwright process (median of 51) 14.26 s 13.10 s

The cache directory for that project was about 2 MB. Memory use of the Playwright process was unchanged.


Headed Mode in Sail

If you run Pest inside Laravel Sail on Windows WSL2 and want headed browser mode, forward WSLg into the container by adding this to your laravel.test service:

environment:
  DISPLAY: ${DISPLAY}
  WAYLAND_DISPLAY: ${WAYLAND_DISPLAY}
  XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR}
  PULSE_SERVER: ${PULSE_SERVER}

volumes:
  - /mnt/wslg:/mnt/wslg
  - /tmp/.X11-unix:/tmp/.X11-unix

This is only required for headed browser mode inside Docker on WSL2. Headless mode works without additional configuration.


Authentication Contract

Default auth route:

/pest-e2e/auth/login

Configure it with:

config('pest-e2e.auth.route');

Security:

  • disabled by default
  • only enabled when PEST_E2E_AUTH_ROUTE_ENABLED=true
  • requires a header, default: X-Pest-E2E: 1
  • tickets are single-use
  • tickets are short-lived, counted from the moment the browser run is launched (time spent queued behind other parallel E2E runs does not count)
  • a ticket that was never exchanged is revoked when its run ends

Reports & Artifacts

Playwright emits its JSON report to stdout. The PHP side parses it in memory and maps it to the canonical pest-e2e.v1 schema.

Playwright artifacts are written to a run-scoped directory:

{reports.base_dir}/{target}/{runId}

The default base directory is:

storage/framework/testing/pest-e2e

Configure it with:

config('pest-e2e.reports.base_dir');

Automatic pruning

Old run directories are removed automatically; there is no command to schedule.

  • Before every Playwright launch pestE2E makes one cheap check (it reads a small timestamp file).
  • At most once per reports.prune.interval_minutes (default 15), one process takes a lock and sweeps old runs. Other Pest workers do not wait; they skip the sweep and launch.
  • A sweep that fails never fails a test. It is reported once on stderr and tried again after the interval.
  • A large backlog is worked off in bounded passes rather than in one go.

What is kept

A run is deleted when it is older than keep_days (default 7) or it is not among the newest keep_runs runs (default 500). Either rule alone is enough, so a run older than keep_days is deleted even if fewer than keep_runs runs exist. Set a rule to 0 to turn it off.

Whatever those rules say:

  • A run that is still in progress is never deleted. Each run holds a lock while it executes, in any Pest worker.
  • A run younger than grace_minutes (default 60) is never deleted, so the traces of a recent failure are still there when you look.

What pestE2E considers its own

Only runs pestE2E registered itself are deleted. The registry lives beside the reports, in {reports.base_dir}/.pest-e2e-state/, because Playwright empties its output directory at the start of every run. Symbolic links are never followed, and nothing outside the report directory is touched.

Directories in the report tree that pestE2E cannot prove it created are left alone. That includes run directories written by versions before this registry existed; see the command below.

Pruning by hand

php artisan pest-e2e:prune-reports --dry-run
Option Effect
(none) Apply keep_days and keep_runs from config
--dry-run List what would be deleted; delete nothing
--unit=days --value=N Delete runs older than N days (replaces both configured rules)
--unit=items --value=N Keep only the newest N runs (replaces both configured rules)
--all Delete every registered run that is not in progress, including recent ones
--legacy Also inspect unregistered directories and report which look like old runs
--delete-unmarked-legacy-runs Delete the unregistered directories that pass validation

The command works even when automatic pruning is disabled, and uses the same rules: it never deletes a run in progress, never follows links, and only works inside reports.base_dir. --all does not extend to unregistered directories.

An unregistered directory is only accepted as an old run when it sits at {base_dir}/{target}/{name}, is a real directory, is empty or contains Playwright's .last-run.json, contains no links, and has not been modified within the grace period. Run with --legacy --dry-run first.

Browser storage state

When a test uses actingAs(), the JS harness writes the session cookies to a Playwright storage-state file. That file is sensitive and is not a report:

  • It is written to a private per-run directory, {reports.base_dir}/.pest-e2e-state/{target}/{runId}/runtime/, passed to the JS process as PEST_E2E_RUNTIME_DIR.
  • It is deleted as soon as the run ends, whether it passed, failed or threw. It is never part of the retained report directory.
  • If a process is killed mid-run, the leftover file is removed by the next sweep once the run's lock is gone.

It must not live inside your Playwright test directory. Playwright walks that whole directory on every invocation, so files left there by earlier runs make every later run slower.

Upgrading from an earlier version. The harness in resources/js/pest-e2e/ is a published copy. Republish it to get the new location:

php artisan vendor:publish --tag=pest-e2e-js-playwright --force

Until you do, pestE2E removes the file your older harness writes (<target dir>/.pest-e2e/<runId>/storageState.json) after each run, so nothing new accumulates. Directories already left there by earlier versions are not removed automatically. If <target dir>/.pest-e2e/ has grown large, deleting it while no tests are running restores normal discovery speed.


Configuration

Key config keys in config/pest-e2e.php:

Key Description
auth.route Auth endpoint path. Default: /pest-e2e/auth/login
auth.route_enabled Enable auth route. Default: false, set via PEST_E2E_AUTH_ROUTE_ENABLED
auth.ttl_seconds Auth ticket TTL, measured from JS-runner launch (not from actingAs()). Default: 60
auth.header.name / auth.header.value Header required for auth requests. Default: X-Pest-E2E: 1
server.driver Server runner: artisan or php_builtin. Default: php_builtin
server.host Bind address for the managed server. Default: 127.0.0.1
server.port Base HTTP port for parallel workers. Default: 8800
server.parallel_port_offset Parallel workers use server.port + TEST_TOKEN. Default: true
reports.base_dir Base directory for Playwright artifacts. Default: storage/framework/testing/pest-e2e
reports.prune.enabled Remove old run directories automatically. Default: true
reports.prune.keep_runs Keep at most this many newest runs; 0 turns the rule off. Default: 500
reports.prune.keep_days Keep runs for at most this many days; 0 turns the rule off. Default: 7
reports.prune.grace_minutes Never delete a run younger than this. Default: 60
reports.prune.interval_minutes Minimum time between automatic sweeps, shared by all workers. Default: 15
timing.enabled Enable timing instrumentation. Default: false, set via PEST_E2E_TIMING
node_compile_cache.enabled Let pestE2E point Node's module compile cache at a per-project temp directory (Node 22.1+). Default: true, set via PEST_E2E_NODE_COMPILE_CACHE
js_runner.driver JS runner. Default: playwright
package_manager Package manager for E2E runs: npm, yarn, pnpm, or bun
parallel.base_port Deprecated alias for server.port
agent_output Force agent JSON output. Default: from PEST_E2E_AGENT_OUTPUT / PAO_FORCE
bindings Contract-to-implementation map for swapping the JS runner

Runner Swapping

pestE2E uses contracts for the JavaScript worker and JSON parser.

The default bindings use Playwright, but you can override them in config/pest-e2e.php to integrate another runner, such as Cypress or Puppeteer.

The important boundary stays the same:

Laravel/Pest owns orchestration.
JavaScript owns browser execution.
JSON owns the report contract.

Final Positioning

pestE2E is not “browser testing for Laravel.”

It is a contract-driven bridge between Laravel and JavaScript-native E2E systems.

If you are building advanced frontend applications and want Laravel to orchestrate while JavaScript owns the browser, this package is for you.

Related Packages

pestphp/pest

The elegant PHP Testing Framework.

96,535,010 11,740
nekofar/pest-plugin-mock-client

Pest Plugin for Mock HTTP client

22 1
quadraecom/pest-plugin-selectors

Add CSS selector-based testing capabilities to Pest

2,035 1
pestphp/pest-plugin-agent

AI agent verification plugin for Pest

717,021 7

Version History

Version Released PHP Laravel License
v1.3.0 ^8.4 ^13.0 MIT
v1.2.0 ^8.4 ^13.0 MIT
v1.1.0 ^8.4 ^13.0 MIT
v1.0.0 ^8.4 ^13.0 MIT