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:
- Re-find the element before each action.
- Wait on a stable condition before reaching for the element.
- 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/customQuiz, check your understanding
Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.