robertfausk/mink-panther-driver
Integrates Symfony Panther with Mink, adding a real-browser driver for Behat and other Mink-based acceptance tests. It uses Panther’s WebDriver/Chrome support to run JavaScript-heavy scenarios with fast, reliable browser automation.
robertfausk/mink-panther-driver is a thin adapter: it implements Mink’s DriverInterface on top of a Symfony\Component\Panther\Client, so Behat/Mink scenarios drive a real Chrome/Firefox instead of Goutte or Selenium Grid. It is not a test framework — Mink still owns sessions, selectors, and assertions. For a Laravel app this is a black-box E2E driver pointed at php artisan serve, Sail, or Valet.
Prerequisites: PHP 8.1+, Composer, Chrome/Chromium. Panther talks WebDriver and can launch chromedriver itself.
composer require --dev robertfausk/mink-panther-driver \
behat/behat behat/mink-extension behat/mink-goutte-driver
Look first at:
vendor/robertfausk/mink-panther-driver/README.md — Behat wiring and options are version-specific.vendor/robertfausk/mink-panther-driver/src/Driver/PantherDriver.php — the source of truth for which Mink methods are implemented and which throw UnsupportedDriverActionException.composer show robertfausk/mink-panther-driver — confirm the installed version and its Panther/Symfony constraints.Minimal smoke test:
<?php
require __DIR__.'/vendor/autoload.php';
use Behat\Mink\Session;
use RobertFausk\MinkPantherDriver\Driver\PantherDriver;
use Symfony\Component\Panther\Client;
$client = Client::createChromeClient(
null,
['--headless=new', '--no-sandbox', '--disable-dev-shm-usage']
);
$session = new Session(new PantherDriver($client));
$session->start();
$session->visit('http://127.0.0.1:8000/');
echo $session->getCurrentUrl(), PHP_EOL;
echo $session->getPage()->find('css', 'h1')?->getText(), PHP_EOL;
$session->stop();
First real use case — keep Goutte as the fast default session and route only @javascript scenarios to Panther:
# behat.yml
default:
extensions:
Behat\MinkExtension:
base_url: http://127.0.0.1:8000
default_session: goutte
javascript_session: panther
sessions:
goutte:
goutte: ~
panther:
panther: ~
# Enable the package's Behat extension here (see its README) so the
# `panther` driver factory above is registered with MinkExtension.
Then run php artisan serve --host=127.0.0.1 --port=8000 and vendor/bin/behat --format=pretty. The base URL host must match exactly what the browser sees, or Laravel session cookies will not stick.
Two-session Behat setup. Use Goutte/BrowserKit for content and redirect assertions, Panther only for scenarios tagged @javascript. This keeps suite time sane; a Chrome boot costs seconds per session.
@javascript
Scenario: Customer can log in
Given I am on "/login"
When I fill in "Email" with "[email protected]"
And I fill in "Password" with "secret"
And I press "Log in"
Then I should see "Dashboard"
Registering the driver yourself. If the installed version has no Behat extension, add a DriverFactory and Behat extension in your test namespace:
namespace App\Behat;
use Behat\MinkExtension\ServiceContainer\Driver\DriverFactory;
use RobertFausk\MinkPantherDriver\Driver\PantherDriver;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Panther\Client;
final class PantherFactory implements DriverFactory
{
public function getDriverName(): string { return 'panther'; }
public function supportsJavascript(): bool { return true; }
public function buildDriver(array $config): PantherDriver
{
return new PantherDriver(Client::createChromeClient(
$config['chromedriver'] ?? null,
$config['arguments'] ?? ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage']
));
}
public function configure(ArrayNodeDefinition $builder): void
{
$builder->children()
->scalarNode('chromedriver')->defaultNull()->end()
->variableNode('arguments')->defaultValue([])->end()
->end();
}
}
namespace App\Behat;
use Behat\MinkExtension\ServiceContainer\MinkExtension;
use Behat\Testwork\ServiceContainer\Extension;
use Behat\Testwork\ServiceContainer\ExtensionManager;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class PantherMinkExtension implements Extension
{
public function getConfigKey(): string { return 'panther_mink'; }
public function initialize(ExtensionManager $extensionManager): void
{
$mink = $extensionManager->getExtension('mink');
if ($mink instanceof MinkExtension) {
$mink->registerDriver('panther', new PantherFactory());
}
}
public function configure(ArrayNodeDefinition $builder): void {}
public function load(ContainerBuilder $container, array $config): void {}
public function process(ContainerBuilder $container): void {}
}
Add App\Behat\PantherMinkExtension: ~ under extensions in behat.yml.
Waiting is a first-class pattern. Mink does not auto-wait; write one step and reuse it everywhere:
/** @When I wait for :selector to be visible */
public function waitForSelector(string $selector): void
{
$session = $this->getSession();
$driver = $session->getDriver();
if ($driver instanceof PantherDriver) {
$driver->getClient()->waitForVisibility($selector, 10_000);
return;
}
$session->wait(10_000, sprintf(
"(function(){var e=document.querySelector(%s);return e && e.offsetParent !== null;})()",
json_encode($selector)
));
}
Escape to Panther for things Mink lacks. The driver should expose the underlying client (e.g. getClient()). Use it for Panther’s waitFor* family, screenshots, and Chrome DevTools scripts; prefer Mink’s executeScript()/evaluateScript() for simple JS when implemented.
$driver = $this->getSession()->getDriver();
if ($driver instanceof PantherDriver) {
$client = $driver->getClient();
$client->waitFor('.data-table tbody tr');
$client->executeScript('window.scrollTo(0, document.body.scrollHeight)');
}
Failure diagnostics hook. Capture a screenshot and browser console on the first failing step:
use Behat\Behat\Hook\Scope\AfterStepScope;
/** @AfterStep */
public function screenshotOnFailure(AfterStepScope $scope): void
{
if ($scope->getTestResult()->isPassed()) {
return;
}
$driver = $this->getSession()->getDriver();
if (!$driver instanceof PantherDriver) {
return;
}
@mkdir('build/behat', 0777, true);
$driver->getClient()->takeScreenshot(sprintf(
'build/behat/%s-%s.png',
date('His'),
md5((string) $scope->getStep()->getText())
));
foreach ($driver->getClient()->getWebDriver()->manage()->getLog('browser') as $entry) {
fwrite(STDERR, sprintf("[browser] %s\n", json_encode($entry)));
}
}
Laravel database and server lifecycle. Behat and php artisan serve are separate PHP processes, so an in-memory SQLite database will not be shared. Use a file-backed SQLite database or a dedicated MySQL schema, and refresh it before the suite:
/** @BeforeSuite */
public static function prepareApp(): void
{
exec('touch database/testing.sqlite');
exec('php artisan migrate:fresh --seed --env=testing --force 2>&1', $output, $status);
if ($status !== 0) {
throw new RuntimeException(implode("\n", $output));
}
}
In CI, start the server as a background job and let Behat connect:
php artisan migrate:fresh --seed --env=testing
php artisan serve --host=127.0.0.1 --port=8000 --env=testing &
vendor/bin/behat --format=pretty --out=std
Keep selectors in page objects. Mink’s NodeElement and DocumentElement make small context helper classes cheap. Centralizing CSS/XPath also isolates you from driver quirks.
Subclass the driver as an extension point. Since the driver is thin, subclassing PantherDriver is often easier than forking: override click() to add a short wait, or implement a missing Mink method against getClient()/getWebDriver().
document.readyState === 'complete' is not enough for Inertia/Vue/React. Wait on a visible element or app-specific marker.getStatusCode()/getResponseHeaders() to be unsupported or unreliable. Assert status codes, redirects, and download responses with a Goutte/BrowserKit session instead.NodeElement across a click that navigates. Re-query from $session->getPage() after every page change.@BeforeScenario hook that calls $session->reset() or creates a fresh client. Cookie bleed is a common source of flaky auth tests.@javascript must map to a JavaScript session. If javascript_session is not configured, Mink silently uses the default Goutte session and JS assertions fail in confusing ways.127.0.0.1 vs localhost) in base_url, the browser, and the server. Laravel’s encrypted session cookie is domain-bound; mixing hosts logs the user out mid-scenario.:memory: SQLite cannot be shared with artisan serve. Use database/testing.sqlite or a test MySQL database and migrate/seed before the suite.--no-sandbox --disable-dev-shm-usage in containers. Pin Chrome and chromedriver versions where possible; auto-download depends on network and the Panther version.PantherDriver source for attachFile, setBasicAuth, resizeWindow, getResponseHeaders, cookie methods, etc. File uploads are particularly brittle with hidden/styled inputs — WebDriver can send keys to the underlying input[type=file], but Mink’s attachFileToField may not find it.alert/confirm must be accepted or dismissed via $client->getWebDriver()->switchTo()->alert(); Mink has no high-level API for this.composer why-not if a conflict appears, and keep this package in require-dev.PANTHER_NO_HEADLESS=1 locally to watch the browser, PANTHER_CHROME_DRIVER_BINARY to pin chromedriver, and screenshots plus manage()->getLog('browser') for console errors. On CI failures, preserve build/behat artifacts.How can I help you explore Laravel packages today?