robertfausk/behat-panther-extension
Behat Panther Extension integrates Symfony Panther with Behat, enabling real-browser acceptance tests in Chrome or Firefox. Configure Panther sessions to run JavaScript-heavy scenarios and get reliable end-to-end coverage for PHP applications.
Minimal path from zero to a green Panther-driven Behat scenario in a Laravel app. As of v2.0.0 the platform floor is PHP 8.2+ and Symfony 5+ (through Symfony 8), so confirm your app qualifies before anything else:
behat.yml.composer require --dev robertfausk/behat-panther-extension:^2.0 symfony/panther
composer require --dev dbrekelmans/bdi
vendor/bin/bdi detect drivers # downloads the chromedriver matching your Chrome
google-chrome --version / chromium --version.panther driver. On Behat 4.x, v2.0.0 also supports a PHP config file (behat.php), which is preferable if you need env-driven config:
# behat.yml (or behat.yml.dist)
default:
suites:
default:
contexts:
- Behat\MinkExtension\Context\MinkContext
- App\Tests\Behat\FeatureContext
extensions:
Behat\MinkExtension:
base_url: 'http://127.0.0.1:8000'
default_session: goutte
javascript_session: panther
sessions:
goutte: ~
panther:
panther: ~
RobertFausk\Behat\PantherExtension: ~
// behat.php — Behat 4.x alternative, lets you read env vars
use Behat\MinkExtension\ServiceContainer\MinkExtension;
use RobertFausk\Behat\PantherExtension;
return [
'suites' => ['default' => ['contexts' => [
Behat\MinkExtension\Context\MinkContext::class,
App\Tests\Behat\FeatureContext::class,
]]],
'extensions' => [
MinkExtension::class => [
'base_url' => getenv('BEHAT_BASE_URL') ?: 'http://127.0.0.1:8000',
'default_session' => 'goutte',
'javascript_session' => 'panther',
'sessions' => ['goutte' => null, 'panther' => ['panther' => null]],
],
PantherExtension::class => [],
],
];
(Key names drift between minor versions — confirm against the package README for the version you pin. The Behat 4.x PHP-config path landed via an unmerged upstream PR, so verify it works on your exact Behat version before standardising on it.)php artisan serve --port=8000, in a background process or a BeforeSuite hook.@javascript
Scenario: Dashboard renders with JavaScript
Given I am on "/dashboard"
Then I should see "Welcome back"
vendor/bin/behat features/dashboard.feature
Where to look first
README.md and src/ extension class — it is small; the extension mostly wires a Mink driver factory around Symfony\Component\Panther\Client.symfony/panther README for browser options (browser, chrome_arguments, web_server, base_uri, screenshot env vars).Behat\MinkExtension docs for session selection (default_session vs javascript_session, tags).bootstrap/app.php / Illuminate\Foundation\Application if you plan to boot the framework from a hook to seed the database.First real use case: you have a flow that Goutte/BrowserKit cannot exercise — Livewire components, Alpine/Vue interactions, Inertia page transitions, file uploads, or anything asserted after an XHR. Tag that one feature @javascript and leave the rest on Goutte so the suite stays fast.
Dual-session split (the highest-value pattern). Keep default_session: goutte for the 80% of scenarios that are just HTTP + HTML assertions, and route only JS-dependent scenarios to Panther via MinkExtension's javascript_session and the @javascript tag. This is the difference between a suite that takes 30 seconds and one that takes 15 minutes, because every Panther scenario spins up a real browser.
Env-driven config now that behat.php exists. With Behat 4.x + v2.0.0 you can build the config programmatically: read BEHAT_BASE_URL, toggle @javascript-only suites in CI, or register a pre-built Panther client when you already have an app server running. Keep a single behat.php as the source of truth and delete the parallel behat.yml — mixing the two is a reliable way to spend an hour debugging "why isn't my session being picked up".
A Laravel-aware base context. Subclass MinkContext (or compose it) and boot Laravel once:
final class FeatureContext extends MinkContext implements Context
{
private static ?Application $app = null;
/** @BeforeSuite */
public static function bootLaravel(): void
{
self::$app = require __DIR__ . '/../../bootstrap/app.php';
self::$app->make(Kernel::class)->bootstrap();
}
/** @BeforeScenario @database */
public function migrateFresh(): void
{
Artisan::call('migrate:fresh', ['--seed' => true]);
}
public function getPantherClient(): Client
{
return $this->getSession('panther')->getDriver()->getClient();
}
}
Use migrate:fresh per scenario or once per suite plus truncation — not DatabaseTransactions. The app under test runs in a separate PHP process (the artisan serve process), so a transaction opened inside the Behat process is invisible to it. Use a file-based SQLite DB or a dedicated MySQL/Postgres test database shared by both processes.
Auth without clicking through login. For speed, either (a) keep one login scenario that exercises the real form, and (b) for everything else generate a temporary signed URL and visit it:
$url = URL::temporarySignedRoute('test.login', now()->addMinutes(5), ['user' => $user->id]);
$this->visit($url);
Guard that route behind app()->environment('testing') and Route::has(...) checks.
Reach for the raw Panther client when Mink is too thin. Mink's API is intentionally small. For waiting, JS evaluation, screenshots, or DOM queries beyond CSS, pull the client out of the driver and use waitForElementToContain, executeScript, takeScreenshot, getCrawler, etc. Wrap these in named steps so features do not leak implementation details.
Hooks for diagnostics. A @AfterStep or @AfterScenario hook that saves a screenshot and the page HTML on failure into var/behat/ is the single biggest debugging quality-of-life win. Panther's own PANTHER_ERROR_SCREENSHOT_DIR covers some cases, but a Behat-level hook gives you the failing step name in the filename.
Page objects for stable selectors. Put data-testid attributes in Blade/Vue/Livewire components and define a small Pages\… class per screen with open(), fill(), submit(). Behat step text stays in business language; selectors stay in one place.
Custom steps for Laravel concerns. Steps like "a verified user exists", "the queue is synchronous", "the cache is cleared", "I travel to tomorrow", and "I run the scheduler" belong in a LaravelContext, not in page-object code. They run in the Behat process, so they can mutate the same database the server reads (again: file/DB-backed, not in-memory SQLite).
CI workflow. On GitHub Actions, provision Chrome, then set PANTHER_CHROME_ARGUMENTS="--no-sandbox --disable-dev-shm-usage" and PANTHER_NO_SANDBOX=1, start the app server, run npm run build (Vite's manifest must exist before @vite renders), and run Behat. Cache the drivers/ directory downloaded by BDI to avoid re-downloading per build. v2.0.0's CI additions cover PHP 8.5 — mirror that in your matrix if you chase the newest runtime, but keep at least one job on your production PHP version.
composer why-not robertfausk/behat-panther-extension ^2.0 before assuming an API problem. Laravel 9/10+/PHP 8.2+ apps upgrade cleanly; anything older stays on 1.x, so record the split in your composer.json constraint (^1.0 || ^2.0 is usually wrong — pick one).vendor/bin/bdi detect drivers whenever Chrome updates, and commit or cache the result.--no-sandbox in containers. Chrome refuses to start as root or without a sandbox. Add --no-sandbox --disable-dev-shm-usage --disable-gpu via Panther options or env vars, otherwise you get a bare "unknown error" from the driver.base_url host mismatch breaks everything. Use the exact same host in base_url, APP_URL, and the URL you actually visit, or Laravel will redirect/scope cookies to a different origin. Prefer 127.0.0.1 over localhost on Linux to avoid IPv6 surprises where the server binds IPv4 only.I should see fails immediately if the page is still hydrating. Add a spin/wait helper (retry the assertion for N seconds) or call Panther's waitFor before asserting. Do not paper over this with sleep().@javascript scenarios but aggressively push assertions into non-JS scenarios. Don't be tempted to keep one long scenario to amortize startup — you lose isolation and failure clarity.APP_ENV=testing with @vite, missing public/build/manifest.json produces an exception page that looks like a routing bug. Run npm run build (or point at a running Vite dev server) before the suite.visit() calls within a scenario, which is usually what you want, but a 419 after a POST typically means the session driver or APP_KEY differs between the test process and the server process, or APP_URL doesn't match. Confirm with a screenshot.PANTHER_NO_HEADLESS=1 to watch the browser, PANTHER_NO_DEBUG=1 to quiet console noise, and vendor/bin/behat --format=pretty --stop-on-failure --name="scenario" to run a single scenario. Add --tags='~@javascript' to skip browser work while iterating on domain logic.NOASSERTION license: lock the version in composer.json, read the license terms before commercial use, and be prepared to fork or swap in a hand-written Mink driver if the extension goes stale. Its surface area is small, so a fork is not a big lift — the extension point is the Mink driver factory; you can also register your own panther driver factory without touching the package.browser, chrome_arguments, web_server, read_timeout) through the extension config rather than hacking env vars everywhere; subclass the extension class if you need to inject a pre-built client or a custom factory (useful for shared test infrastructure like an already-running app server). With the PHP config file these options are plain arrays you can compute at runtime.How can I help you explore Laravel packages today?