Scraping Central is reader-supported. When you buy through links on our site, we may earn an affiliate commission.

2.15intermediate4 min read

Selenium in PHP via `php-webdriver/webdriver`

Selenium for PHP. Maintained, W3C-compliant, the right tool when Panther doesn't fit or you need raw WebDriver control.

What you’ll learn

  • Install `php-webdriver/webdriver` and start ChromeDriver.
  • Translate the Selenium-Python concepts to PHP equivalents.
  • Use `WebDriverWait` and `WebDriverExpectedCondition` correctly in PHP.
  • Decide when to use php-webdriver vs Symfony Panther.

php-webdriver/webdriver is the PHP port of Selenium's WebDriver client. Mature, W3C-compliant, and the foundation Symfony Panther builds on top of. When you need raw WebDriver control without Panther's Symfony-flavoured abstractions, this is the library.

Install

composer require php-webdriver/webdriver

The library itself is pure PHP. The actual browser-driving binary (ChromeDriver, geckodriver, etc.) you provide separately:

# macOS via Homebrew
brew install --cask chromedriver

# Linux: download manually from https://chromedriver.chromium.org/
# Or rely on Selenium Server in front of it

Unlike Selenium 4 in Python, php-webdriver does not have a built-in driver manager. You either install ChromeDriver into PATH, run Selenium Server (the standalone JAR), or run Docker images that bundle both.

Run ChromeDriver

chromedriver --port=9515 &

ChromeDriver listens for WebDriver commands on port 9515 by default. Your PHP scraper connects to that port.

For team environments and CI, run a Selenium Server hub instead, it's the same protocol but supports multi-browser routing.

Your first scraper

<?php
require __DIR__ . '/vendor/autoload.php';

use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Facebook\WebDriver\WebDriverWait;

$options = new ChromeOptions();
$options->addArguments([
  '--headless=new',
  '--no-sandbox',
  '--disable-dev-shm-usage',
  '--window-size=1280,800',
]);

$caps = DesiredCapabilities::chrome();
$caps->setCapability(ChromeOptions::CAPABILITY, $options);

$driver = RemoteWebDriver::create('http://localhost:9515', $caps);

try {
  $driver->get('https://practice.scrapingcentral.com/');

  (new WebDriverWait($driver, 10))->until(
  WebDriverExpectedCondition::presenceOfElementLocated(WebDriverBy::cssSelector('h1'))
  );

  $h1 = $driver->findElement(WebDriverBy::cssSelector('h1'))->getText();
  echo "$h1\n";
} finally {
  $driver->quit();
}

Compared to Selenium Python, the shape is almost identical, uppercase camelCase methods (getText vs text), and PHP's heavy namespace usage. Everything maps one-to-one.

Concept mapping

Selenium Python php-webdriver PHP
webdriver.Chrome() RemoteWebDriver::create('http://localhost:9515', $caps)
driver.get(url) $driver->get($url)
By.CSS_SELECTOR WebDriverBy::cssSelector(...)
driver.find_element(...) $driver->findElement(...)
driver.find_elements(...) $driver->findElements(...)
el.send_keys("...") $el->sendKeys("...")
el.click() $el->click()
WebDriverWait + EC WebDriverWait + WebDriverExpectedCondition
driver.execute_script(...) $driver->executeScript(...)
driver.quit() $driver->quit()

If you can read one, you can read the other.

Explicit waits

The WebDriverWait + condition pattern in PHP:

$wait = new WebDriverWait($driver, 10, 500);  // 10s timeout, 500ms polling

$wait->until(
  WebDriverExpectedCondition::elementToBeClickable(
  WebDriverBy::cssSelector('button.submit')
  )
);

Built-in conditions include:

WebDriverExpectedCondition::presenceOfElementLocated($by);
WebDriverExpectedCondition::visibilityOfElementLocated($by);
WebDriverExpectedCondition::invisibilityOfElementLocated($by);
WebDriverExpectedCondition::elementToBeClickable($by);
WebDriverExpectedCondition::textToBePresentInElement($by, 'Loaded');
WebDriverExpectedCondition::numberOfWindowsToBe(2);
WebDriverExpectedCondition::alertIsPresent();

Custom conditions:

$wait->until(function ($driver) {
  return count($driver->findElements(WebDriverBy::cssSelector('.product-card'))) >= 24;
});

A closure that returns truthy when the condition is met. Same pattern as Python's lambda-as-condition.

Form interaction

$email = $driver->findElement(WebDriverBy::name('email'));
$email->clear();
$email->sendKeys('demo@example.com');

$pass = $driver->findElement(WebDriverBy::name('password'));
$pass->sendKeys('password');

$driver->findElement(WebDriverBy::cssSelector('button[type=submit]'))->click();

clear() + sendKeys() for inputs (same as Selenium Python). Send a key code like Enter:

use Facebook\WebDriver\WebDriverKeys;
$pass->sendKeys(WebDriverKeys::ENTER);

Actions (drag, hover, modifier-key combos)

use Facebook\WebDriver\Interactions\WebDriverActions;

$action = new WebDriverActions($driver);

$action
  ->moveToElement($menu)
  ->pause(500)
  ->moveToElement($submenu)
  ->click()
  ->perform();

// Drag-and-drop
$action
  ->clickAndHold($source)
  ->moveToElement($target)
  ->release()
  ->perform();

Same ActionChains-style sequencing as Selenium Python. Verbose but explicit.

When to use php-webdriver vs Panther

php-webdriver gives you raw WebDriver semantics. Panther wraps it in a Symfony BrowserKit/DomCrawler interface. Pick per your needs:

Need Pick
Symfony project, want crawler API + DI integration Panther
Non-Symfony PHP, just need browser automation php-webdriver
Running against Selenium Grid / Saucelabs / BrowserStack php-webdriver
Want filter() / form() / selectButton() helpers Panther (it's on top of php-webdriver)
Need cross-browser via the WebDriver protocol Either; php-webdriver is more direct
Mock-friendly testing Panther (has PantherTestCase)

Panther actually uses php-webdriver under the hood. Choosing Panther doesn't lose any php-webdriver capability, $client->getWebDriver() exposes the raw driver when you need it.

Common gotcha: stale element

$row = $driver->findElement(WebDriverBy::cssSelector('tr.first'));
// ... page re-renders ...
$row->getText();  // StaleElementReferenceException

The element reference goes stale on re-render. Three fixes:

  1. Re-find the element before each action.
  2. Wait on a stable condition before reaching for the element.
  3. Wrap in retry logic with a max-retries cap.

This pain is the #1 reason teams migrate Selenium scrapers to Playwright. Playwright's locator API doesn't go stale because it re-queries on every action.

Cleanup

try {
  $driver = RemoteWebDriver::create('http://localhost:9515', $caps);
  // ...
} finally {
  if (isset($driver)) {
  $driver->quit();
  }
}

quit() closes the browser AND the WebDriver session. Without it, ChromeDriver keeps the session alive (and the browser process running) until you restart ChromeDriver itself.

Hands-on lab

Open /challenges/dynamic/date-picker/custom. Write a php-webdriver script that opens the picker, selects a date, and reads the resulting input value. Use WebDriverWait for every interaction. Then port the same scrape to Panther and compare, the Panther version should feel more idiomatic if you're inside a Symfony project, and roughly equivalent otherwise.

Hands-on lab

Practice this lesson on Catalog108, our first-party scraping sandbox.

Open lab target → /challenges/dynamic/date-picker/custom

Quiz, check your understanding

Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.

Selenium in PHP via `php-webdriver/webdriver`1 / 8

Which Composer package is the canonical Selenium WebDriver client for PHP?

Score so far: 0 / 0