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

Browser Laravel Package

zenstruck/browser

zenstruck/browser is an expressive, fluent wrapper around Symfony's functional testing features, letting you visit pages, assert content, fill fields, and click buttons in chainable code. Pair it with zenstruck/foundry for succinct, readable tests.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up in two minutes:

composer require zenstruck/browser --dev

Then enable the PHPUnit extension in phpunit.xml so failures automatically dump the rendered source (and screenshots / JS console logs for Panther) to var/browser/:

<!-- PHPUnit 8/9 -->
<extensions>
    <extension class="Zenstruck\Browser\Test\BrowserExtension" />
</extensions>

<!-- PHPUnit 10+ -->
<extensions>
    <bootstrap class="Zenstruck\Browser\Test\BrowserExtension" />
</extensions>

First thing to look at: the HasBrowser trait and the Zenstruck\Browser method list in the README. The library's whole value proposition is auto-completion — in a good IDE you do not need the docs after the first read; you type $this->browser()-> and pick from the dropdown. That is the fastest way to learn the surface area.

Minimal working test:

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Browser\Test\HasBrowser;

class PostTest extends WebTestCase
{
    use HasBrowser;

    public function test_show_post(): void
    {
        $this->browser()
            ->visit('/posts/3')
            ->assertSuccessful()
            ->assertSeeIn('h1', 'My First Post')
            ->fillField('Comment', 'Nice one')
            ->click('Submit')
            ->assertOn('/posts/3')
            ->assertSeeIn('#comments', 'Nice one')
        ;
    }
}

First real use case: take your ugliest existing WebTestCase — the one with six $client->request(), $crawler->filter()->text() and assertSame() calls — and rewrite it as a single chain. That is the moment you decide whether this package earns its place in your composer.json. KernelBrowser chains are typically 40–60% shorter than the BrowserKit equivalent, and the diff reads like a user story instead of a DOM query log.

Prerequisites by browser:

Browser Test base class Speed JS
browser() (Kernel) KernelTestCase / WebTestCase fast no
pantherBrowser() Symfony\Component\Panther\PantherTestCase slow yes

Version note: current release is v1.13.0. Two of its changes are worth knowing before you write your first test — ->expectException() now ends the browser session (see Gotchas), and ->use() gained the HTTP session as an injectable. Neither is breaking unless you were relying on the old lenient behaviour.

Implementation Patterns

Chain-per-scenario. Treat one browser session as one user story and keep it linear. Each step returns the browser, so assertions can be interleaved with actions — assert after the click, not at the end of the test, so a failure tells you which step broke.

Pair with Foundry for fixtures. The README's own example is the strongest pattern in the package:

$post = PostFactory::new()->create(['title' => 'My First Post']);

$this->browser()
    ->visit("/posts/{$post->getId()}")
    ->assertSeeIn('h1', 'My First Post');

No hardcoded IDs, no fixtures YAML, and the assertion values come from the same source of truth as the data.

Use ->use() instead of breaking the chain. When you need a side effect that isn't a browser action (expire a cookie, seed the session, grab a service, call a factory), inject by type-hint inside the closure rather than assigning $browser to a variable and resuming later. Supported injections: Zenstruck\Browser, Symfony\Component\BrowserKit\AbstractBrowser, the CookieJar, the current Crawler, and — as of v1.13 — the HTTP session (SessionInterface).

$this->browser()
    ->visit('/cart')
    ->use(fn(SessionInterface $session) => $session->set('coupon', 'SAVE10'))
    ->visit('/checkout')
    ->assertSee('SAVE10')
;

Session injection is the clean way to test flash-message, cart and multi-step-wizard behaviour without reaching for $client->getContainer()->get('session') and hoping the session is started. Combine it with ->disableReboot() only if you actually need the same session object to persist across requests.

Use ->use() for cookie expiry, too. Injecting by type-hint works as well for the cookie jar:

$this->browser()
    ->visit('/admin')
    ->use(fn(CookieJar $jar) => $jar->expire('MOCKSESSID'))
    ->visit('/admin')
    ->assertRedirected()
;

Split suites by browser type. Use KernelBrowser for everything that doesn't need JavaScript — form posts, redirects, auth, APIs, profile assertions. Reserve PantherBrowser for the handful of pages that genuinely need JS (modals, autocomplete, Stimulus/Turbo flows). Mixing them in one class works via HasBrowser, but tagging Panther tests in a separate group keeps the fast suite fast.

API endpoint testing with HttpOptions. This is arguably the most underrated part of the package — you get a fluent HTTP client for free:

$this->browser()
    ->post('/api/posts', HttpOptions::json(['title' => 'Hi']))
    ->assertStatus(201)
    ->assertJsonMatches('title', 'Hi')
    ->assertJsonMatches('length(tags)', 0)
;

HttpOptions::create()->withHeader(...)->withBody(...) is available when you need something the shortcuts don't cover. JMESPath assertions (assertJsonMatches, assertHas, assertMissing, search) need composer require --dev mtdowling/jmespath.php.

Keep request options per-call. Since v1.13 the default HttpOptions are no longer mutated when request options are merged, so a ->post(..., HttpOptions::json(...)) no longer bleeds its Content-Type/Accept headers into later requests on the same browser. The pattern that matters: pass options explicitly on each call rather than assuming state carries — it now genuinely doesn't. On ≤1.12, upgrade if you've seen phantom JSON headers on subsequent GETs.

Authentication as a one-liner. For KernelBrowser:

$this->browser()
    ->actingAs($user)
    ->visit('/account')
    ->assertAuthenticated($user)
;

This replaces the customary $client->loginUser() + manual session fiddling and gives you a matching assertion, which is useful for negative tests too (assertNotAuthenticated()).

Profiler assertions for N+1 hunts. Because functional tests can now reach the profiler, you can assert query counts on a real page render:

$count = $this->browser()
    ->withProfiling()
    ->visit('/posts')
    ->profile()
    ->getCollector('db')
    ->getQueryCount()
;

withProfiling() must come before the request. If you assert on the profiler often, set framework.profiler.collect: true in config/packages/test/ and drop the call.

Wrap the trait in a project base class. Rather than repeating actingAs() boilerplate, add small helpers to an AppTestCase that calls $this->browser()->use(...) or forwards to the trait. This is also the sane place to add domain-specific assertions (assertLoggedInAsAdmin()).

Gotchas and Tips

Exception catching flips the default. KernelBrowser converts kernel exceptions into responses by default. That means $this->expectException() from PHPUnit silently never fires — the request "succeeds" with a 500. Either call ->throwExceptions() first, or use the package's own ->expectException(MyException::class, 'msg')->post(...), which asserts on the next request. The default is configurable via BROWSER_CATCH_EXCEPTIONS.

->expectException() now consumes the browser (v1.13). After the expected exception has been matched, acting on the browser again throws instead of silently doing nothing. Practically: the exception assertion must be the last link in the chain, and you cannot ->expectException(...)->post('/boom')->visit('/'). If you need to assert the exception and continue, split into two tests or a second $this->browser() instance. This is a bug fix, not a breaking change, but it will surface previously-silent chains that were passing for the wrong reason.

The kernel reboots between requests by default. Convenient for isolation, surprising when it isn't: Doctrine entities you created become detached, in-memory services reset, and mock expectations set up outside the request may not survive. ->disableReboot() is the fix, but reach for it deliberately — it's a common source of order-dependent flakiness.

Redirects are followed by default. To assert on a 3xx response you need ->interceptRedirects() before the action, then ->assertRedirected() and optionally ->followRedirect(). assertRedirectedTo('/path') bundles the three steps. clickAndIntercept() is the specific tool for "submit form, assert on the redirect response rather than the landing page". Default is overridable with BROWSER_FOLLOW_REDIRECTS.

->dd() kills the process. Fine locally, catastrophic in CI. ->dump() is the safe one for committed code — and it is genuinely good: ->dump('h1') for HTML, ->dump('foo.bar') for JSON, JMESPath wildcards included.

JSON/HTML assertion scopes differ. assertContains / assertNotContains scan the raw body (good for XML/plain text); assertSee / assertSeeIn parse as HTML (good for markup). Mixing them up produces false negatives on JSON responses. assertJsonMatches helpfully calls assertJson() for you.

Remember-me + lazy firewall breaks assertAuthenticated(). The documented LogicException: Cannot create the remember-me cookie; no master request available. has three fixes: visit a page that calls is_granted(), call withProfiling() before the preceding request, or enable the profiler for the whole test env. Pick one and document it in your base test class so the next developer doesn't rediscover it.

KernelBrowser and PantherBrowser are not interchangeable. actingAs(), assertJson(), withProfiling(), get/post/put/delete, expectException() are kernel-only. If you write shared test helpers, type-hint Zenstruck\Browser and stay within the common surface, or you'll hit a BadMethodCallException on the one test that switched to Panther. Session injection via ->use() is likewise kernel-flavoured — check it works under Panther before relying on it in a shared helper.

Artifact directory needs to exist and be writable. Default is <project-root>/var/browser/source, env-overridable via BROWSER_SOURCE_DIR. Add var/browser to .gitignore and make sure CI preserves it as a build artifact — a captured failure source is often worth more than the stack trace. For Panther, screenshots and JS console logs come along for free, which is the closest thing to a Cypress-style failure report you'll get without Cypress. Under paratest, filenames are unique per test as of v1.13, so parallel workers no longer overwrite each other's dumps — before that, a green-looking artifact folder with half the failures missing was a real (and confusing) possibility.

Escape hatches are real, not theoretical. ->crawler() gives you the raw DomCrawler, ->content() gives the raw body, and ->use(fn(AbstractBrowser $b) => ...) reaches the underlying BrowserKit client. There's nothing this wrapper prevents you from doing, which matters when an assertion has no fluent equivalent.

Honest assessment for adoption. The dependency is thin (it decorates Symfony's own BrowserKit/KernelBrowser/Panther), so the risk of lock-in is low — if the package stalled tomorrow, unwinding is a mechanical refactor back to $client->request(). It's a small, single-maintainer package (229 stars, no dependents tracked), so buy in for velocity, not for ecosystem gravity. The opportunity/benefit calculation is straightforward: if your suite has more than a handful of functional WebTestCase classes, the readability and auto-completion gains pay for the dependency within the first sprint. If you have two such tests, skip it. And if your real need is JavaScript, know that Panther's chromedriver overhead dominates everything else — this package makes the tests prettier and failures more diagnosable, but it won't make browser tests fast.

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.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource