Continuous Integration
Install browsers, cache dependencies, and collect artifacts in CI.
CI should prove that your browser tests work on a clean machine. The reliable recipe is simple: install PHP dependencies, install Node 20+, install Playwright browsers with system dependencies, run the test suite, and keep failure artifacts.
GitHub Actions default
The playwright-php/setup-playwright action installs the Playwright npm runtime, selected browsers, and Linux browser dependencies. Keep PHP, Composer, caching, application setup, and artifacts as explicit steps:
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- uses: playwright-php/setup-playwright@v1
with:
playwright-version: '1.62.1'
browsers: chromium
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit --colors=always
- name: Upload browser artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: browser-artifacts
path: |
test-failures/
var/artifacts/
retention-days: 7
Keep the workflow boring. Debugging CI is hard enough without clever shell wrappers.
What CI should prove
CI is not just another place to run the same command as your laptop. It should prove that the project is reproducible from a clean checkout.
For Playwright PHP, that means:
- Composer dependencies install without local caches or unpublished packages;
- Node.js is available for the Playwright driver process;
- browser binaries exist for the operating system used by the runner;
- the application under test can start from environment variables and fixtures;
- failed browser tests leave enough evidence to diagnose without rerunning blindly.
Keep those responsibilities visible in the workflow. If the setup step becomes a long shell script, split it into named steps: install PHP dependencies, install browsers, prepare database, start app, run tests, upload artifacts. Named steps make failures searchable and make the next maintainer faster.
Manual installation
If you do not use the action, install the pieces explicitly:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: composer install --no-interaction --prefer-dist
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('composer.lock') }}
restore-keys: playwright-
- run: vendor/bin/playwright-install --with-deps chromium
- run: vendor/bin/phpunit --colors=always
Use --with-deps on fresh Linux runners. Passing chromium limits the installation to that browser target. Use --browsers when the job needs Chromium, Firefox, and WebKit.
Split fast and browser-backed tests
Mark tests that launch a browser as integration tests:
use PHPUnit\Framework\Attributes\Group;
#[Group('integration')]
final class CheckoutTest extends TestCase
{
// ...
}
Then run quick feedback separately:
vendor/bin/phpunit --exclude-group integration
vendor/bin/phpunit
This makes pull-request feedback faster and lets scheduled jobs run the wider browser matrix.
Start the application explicitly
Browser tests need a real HTTP server. In CI, start it as an explicit step and fail early if it does not answer.
- name: Start test server
run: symfony server:start --no-tls --port=8000 --daemon
- name: Wait for server
run: curl --fail --retry 20 --retry-delay 1 http://127.0.0.1:8000/
- name: Run browser tests
env:
APP_TEST_URL: http://127.0.0.1:8000
run: vendor/bin/phpunit --group integration
The exact server command depends on your application. The important rule is the contract: tests should receive the base URL from the environment, not hard-code a developer machine URL.
If PHP and the browser run in separate containers, 127.0.0.1 points to the current container only. Use the service name or published port that the browser process can actually reach.
Keep data setup deterministic
The browser should not depend on whatever data happened to be left by a previous job. Prepare the database, cache, mailer, queue, and storage directories as part of the CI job. If the application needs a user account, create it from a fixture, factory, or setup command before the browser suite starts.
Avoid tests that rely on production-like shared accounts. They introduce order dependence and make parallel jobs unsafe. A browser scenario should own the records it needs, or at least run against a known fixture snapshot.
For flows that send email, prefer a test mailer or local inbox service and assert the visible result through that service. For queued work, either run the worker explicitly or configure the test environment to process jobs synchronously. The goal is not to remove integration; it is to make the integration contract repeatable.
Matrix and environment defaults
Test the oldest supported PHP version plus the version you develop on. For browser engines, run Chromium on every PR and add Firefox/WebKit on nightly or protected branches unless the project needs full coverage on every change.
Pin deterministic browser context defaults in your tests: viewport, locale, timezone, and storage state. CI runners rarely match your laptop.
Read credentials from secrets:
env:
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
Never commit generated authentication state with real cookies.
Timeouts are a signal, not a CI strategy
It is tempting to make every timeout larger when CI is slower than a laptop. Do that only after you have evidence that the application legitimately needs more time.
Prefer these fixes first:
- wait for a user-visible result instead of an implementation detail;
- remove unnecessary network mocking or make the route pattern narrower;
- seed less data for browser scenarios;
- capture console and request failures;
- run one browser engine on pull requests and expand the matrix elsewhere.
When you do raise a timeout, do it close to the slow operation and explain why. A global timeout increase makes real hangs expensive.
Artifacts and tracing
Upload only useful artifacts: screenshots, traces, logs, and generated reports. In PHPUnit runs, failed browser tests can write to test-failures/. If you enable tracing with PW_TRACE=1, upload the trace zip on failure and inspect it locally.
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: failure-artifacts
path: |
test-failures/
storage/logs/playwright*.log
Troubleshooting
- Browser install fails: check Node 20+, free disk space, and use
vendor/bin/playwright-install --with-deps chromium. - Tests pass locally but fail in CI: compare PHP version, viewport, timezone, and installed fonts. Capture a trace before raising timeouts.
- Browsers download every run: restore
~/.cache/ms-playwrightbefore the install step and key it fromcomposer.lock. - Authentication fails: confirm secrets are present and regenerate storage state in CI before tests.
- Headed-only failure: run headless locally too. CI has no real display unless you configure one.
Go next
- Structure the suite: Testing with PHPUnit
- Prepare authenticated users: Authentication and state
- Pin the browser environment: Devices and emulation
- Investigate CI-only failures: Debugging