Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Page Object Extension Laravel Package

friends-of-behat/page-object-extension

Behat extension providing base Page and Element classes for the page object pattern, plus SymfonyPage for straightforward Symfony support. Extracted from Sylius and inspired by SensioLabs, it helps structure and reuse your acceptance test code.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require --dev friends-of-behat/page-object-extension

You also need Behat and a Mink driver; the package ships no Behat extension, no DI wiring, no services. It is three abstract classes (Page, Element, SymfonyPage). Expect to read the whole src/ in five minutes — that is not a red flag, it is the package.

First place to look:

  1. src/Page/Page.php — base page. Constructor takes the Mink Session plus an optional $parameters array. open() calls an abstract getUrl(). getElement(string $name) resolves named selectors from getDefinedElements().
  2. src/Page/Element.php — base element. Same element-resolution machinery, plus an abstract selector for its own root node.
  3. src/Page/SymfonyPage.php — adds container/router awareness via Symfony's RouterInterface and container parameters. Relevant only if you are actually on Symfony; for Laravel you write your own adapter.

A minimal first use case — a login page with one step definition:

// tests/Behat/Page/LoginPage.php
namespace Tests\Behat\Page;

use FriendsOfBehat\PageObjectExtension\Page\Page;

final class LoginPage extends Page
{
    protected function getUrl(array $urlParameters = []): string
    {
        return '/login'; // or route('login') in a LaravelPage subclass
    }

    public function fillCredentials(string $email, string $password): void
    {
        $this->getElement('email')->setValue($email);
        $this->getElement('password')->setValue($password);
    }

    public function submit(): void
    {
        $this->getElement('submit')->click();
    }

    protected function getDefinedElements(): array
    {
        return [
            'email'    => '[data-testid=login-email]',
            'password' => '[data-testid=login-password]',
            'submit'   => '[data-testid=login-submit]',
        ];
    }
}
// tests/Behat/Context/AuthenticationContext.php
namespace Tests\Behat\Context;

use Behat\Behat\Context\Context;
use Behat\MinkExtension\Context\MinkContext;
use Tests\Behat\Page\LoginPage;

final class AuthenticationContext extends MinkContext implements Context
{
    private LoginPage $loginPage;

    /** @BeforeScenario */
    public function bootPages(): void
    {
        $this->loginPage = new LoginPage($this->getSession());
    }

    /** @When I sign in as :email with :password */
    public function iSignInAs(string $email, string $password): void
    {
        $this->loginPage->open();
        $this->loginPage->fillCredentials($email, $password);
        $this->loginPage->submit();
    }
}

That is the whole first iteration. Resist adding elements until a selector is used in two or more steps.

Implementation Patterns

Page per route, element per widget. A Page maps to one URL/route and owns that screen's selectors. An Element maps to a repeated fragment — navbar, data table, address form — and is owned by a page. The rule of thumb: if you would reuse it in a second scenario, it becomes a class; if it is a single field, it stays a selector in getDefinedElements().

Compose elements, do not inherit them. An element is instantiated by the page and passed the session plus the parent page context. Pages call into elements; steps call into pages. Never let a step definition touch $this->getSession()->getPage()->find(...) directly — that is the whole point of the abstraction.

final class ProductListPage extends Page
{
    public function getProductCount(): int
    {
        return $this->getElement('table')->countRows();
    }

    public function openFirstProduct(): void
    {
        $this->getElement('table')->clickRowLink(0, 'View');
    }

    protected function getUrl(array $urlParameters = []): string
    {
        return route('products.index');
    }

    protected function getDefinedElements(): array
    {
        return ['table' => '[data-testid=product-table]'];
    }
}

Write a LaravelPage base once, and never touch route() in individual pages.

abstract class LaravelPage extends Page
{
    protected function getUrl(array $urlParameters = []): string
    {
        return route($this->routeName, $urlParameters);
    }

    // SymfonyPage exposes getParameter(); Laravel's equivalent:
    protected function getParameter(string $name, mixed $default = null): mixed
    {
        return config($name, $default);
    }
}

Then every page overrides only routeName, keeping URL knowledge in one place and letting fixtures/seeders vary base URLs per environment.

Thin contexts, fat page objects. Contexts should read as Gherkin: parse arguments, call two or three page methods, hand values to PHPUnit-style assertions. All Mink waiting, scrolling, and JS-driven clicking lives inside elements/pages. This is what keeps step definition files from becoming 800-line god objects.

Return data, assert in the step. A page method getFlashMessage(): string is more reusable than assertFlashMessageIs(...). Keep assertions in the step so failures surface in Behat output with the scenario context.

Registry pattern for shared pages. Once you have more than a handful of pages, wrap instantiation:

final class PageRegistry
{
    public function __construct(private Session $session) {}

    public function login(): LoginPage { return new LoginPage($this->session); }
    public function cart(): CartPage   { return new CartPage($this->session); }
}

Inject the registry once per scenario via @BeforeScenario, not per step. If you are on Symfony with Behat\Symfony2Extension, you can instead register pages as container services and autowire them into contexts; on Laravel, app(LoginPage::class) works if you bind them in a test service provider.

Parameterise pages for multi-tenant / multi-locale suites. The $parameters array in the constructor is the intended hook. Pass a locale or tenant slug and use it in getUrl() — this is far cleaner than duplicating ProductListPage per locale.

Use elements for JS-heavy islands. A modal or autocomplete widget is a textbook element: it has a stable root selector, its own internal selectors, and its own open/close timing. Give it explicit waitFor() methods rather than sprinkling $session->wait(2000) around.

Keep step definitions and page classes in your own namespace (Tests\Behat\Page\...) and let Composer autoload them via autoload-dev. The package does not scan, discover, or register anything.

Gotchas and Tips

SymfonyPage will not work in Laravel as-is. It reaches for Symfony's router and ContainerInterface::getParameter(). Laravel has neither in that shape. Either extend Page directly, or subclass SymfonyPage and override getUrl() / getParameter() to delegate to route() and config(). Do not try to instantiate SymfonyPage before doing that — you will get cryptic container errors that look like Behat misconfiguration.

No extension registration means no config. There is nothing to add under extensions: in behat.yml. If you are hunting through Behat's --config-reference for this package, stop. Your only configuration is your own convention: which namespace holds pages, how the session is obtained, and what Mink driver you use.

Watch the version/Behat pairing. 0.3.x is the PHP 8 / Behat 3 era; 0.4.0 modernised CI for Behat 4 + Symfony 8. If your suite is on Behat 3, pin ^0.3. If you are starting fresh on Behat 4, use ^0.4. Check composer.lock after the first install — a mismatch here produces confusing Class not found failures on Behat's side, not the package's.

Dev dependency vs. CI. It is installed with --dev. Any CI job running composer install --no-dev for the acceptance stage will break. Either install dev dependencies in the test job, or promote the package to a regular dependency in the acceptance-testing image.

getUrl() must be absolute-enough. Page::open() hands the URL to Mink's visit(). Relative paths only resolve if base_url is set in the MinkExtension config. If open() navigates to /login literally instead of http://localhost:8000/login, that is the missing base_url, not the package.

Element nodes go stale. getElement() resolves a fresh NodeElement on each call, so do not cache NodeElement instances across a page reload or an SPA navigation. Cache the page or element object; call getElement() again after anything that re-renders the DOM.

Selectors: prefer stable hooks. [data-testid=...] in getDefinedElements() survives CSS refactors in a way that .btn.btn-primary.mt-3 does not. Because all the drift is concentrated in one method per page class, a markup change becomes a one-line diff — this is the single biggest day-to-day payoff.

Debugging a failing page object is mostly a Mink problem, not a package problem. In order:

  • vendor/bin/behat -vvv --stop-on-failure for the actual Mink exception (Element not found, Element is not visible).
  • --format=pretty to see which step died.
  • From inside the page: $this->getSession()->getDriver()->getContent() or a screenshot dump on @AfterStep failures.
  • If selectors look right but the find fails, check whether the scenario is tagged @javascript — a non-JS scenario uses Goutte and will never see anything rendered client-side.

Do not over-granularise elements. One element per form field is the classic over-correction: you end up with EmailElement and PasswordElement and the page is now harder to read than the raw Mink calls. Group by visual component; a form with eight fields is one element.

The codebase is ~150 lines. Before forking, wiring up a maintainer, or writing a bespoke LaravelPage hierarchy, consider vendoring the three abstract classes into tests/Behat/ and deleting the dependency. Given 177 stars, zero dependents, and a quiet changelog between 2020 and the recent 0.4.0, that is a legitimate exit strategy — and the extension points (getUrl, getDefinedElements, getElement, the $parameters array) are few enough that a local copy costs almost nothing to maintain.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource