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 Kit Laravel Package

symfony/browser-kit

Symfony BrowserKit simulates a web browser in PHP, letting you make requests, click links, and submit forms programmatically. Includes an HttpClient-based implementation for real HTTP requests, ideal for functional tests and scraping.

View on GitHub
Deep Wiki
Context7

Getting Started

What you're actually installing. BrowserKit is a browser simulation abstraction, not an HTTP client and not a test runner. It ships AbstractBrowser (request/click/submit/back/forward/restart), CookieJar, History, Request/Response value objects, and exactly one concrete implementation: HttpBrowser, which makes real network calls through symfony/http-client. The piece that turns a simulated request into a kernel dispatch (KernelBrowser) lives in symfony/framework-bundle, not here — so in a Laravel app you either talk to a running server over the network, or you write ~200–400 LOC of glue yourself.

Install (dev-only in the common case):

composer require --dev symfony/browser-kit symfony/css-selector

symfony/dom-crawler comes as a hard dependency; symfony/css-selector is needed for CSS-style selectors (filter('.alert-danger')). Both are net-new to a default Laravel install.

Pick the version line by PHP, not by taste. Symfony 8 requires PHP >= 8.4 and enforces return types across all components. If your app runs 8.2/8.3, pin the 7.4 line:

{ "require-dev": { "symfony/browser-kit": "^7.4.17", "symfony/css-selector": "^7.4" } }

Pin >= 7.4.17 / >= 8.1.9 specifically — those carry the cookie fixes (plus-sign decoding, most-specific-cookie matching) you will hit the first time a session cookie contains a +.

Where to look first. In this order:

  1. The docs intro: https://symfony.com/doc/current/components/browser_kit/introduction.html (README only points there).
  2. AbstractBrowser — every public method you'll call is defined here. Read request(), click(), clickLink(), submitForm(), selectLink(), selectButton(), jsonRequest(), followRedirects(), maxRedirects(), restart().
  3. HttpBrowser::doRequest() — the only 80 lines that matter for understanding what actually goes on the wire.
  4. DomCrawler\Crawler — you will spend more time in filter()/text()/attr() than in BrowserKit itself.

First use case: the login journey, against a real server. This is where BrowserKit earns its keep over Laravel's built-in $this->post() helpers — the app runs as a separate process, over HTTP, with a real cookie jar and real CSRF round-trip.

use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

$browser = new HttpBrowser(HttpClient::create([
    'base_uri' => 'http://localhost:8000',
    'timeout'  => 10,
]));

$crawler = $browser->request('GET', '/login');

$form = $crawler->selectButton('Log in')->form();
$form['email']    = '[email protected]';
$form['password'] = 'secret';

$crawler = $browser->submit($form);

// Redirects are followed automatically; you land on /dashboard
$this->assertSame(200, $browser->getResponse()->getStatusCode());
$this->assertCount(1, $crawler->filter('h1:contains("Dashboard")'));

// The session cookie is in the jar, reused by every later request
$this->assertNotNull($browser->getCookieJar()->get('laravel_session'));

Note the _token hidden input: BrowserKit's Form serializes all fields including hidden ones, so CSRF just works — provided the form was fetched in the same browser instance, which is where the shared cookie jar pays off.

Implementation Patterns

1. A base test case that owns the browser lifecycle

Don't new HttpBrowser in every test. One abstract base, one freshly-restarted browser per test, health-checked before the suite:

abstract class BrowserTestCase extends \Tests\TestCase
{
    protected HttpBrowser $browser;

    protected function setUp(): void
    {
        parent::setUp();

        $this->browser = new HttpBrowser(HttpClient::create([
            'base_uri' => rtrim(config('testing.base_url'), '/') . '/',
            'timeout'  => 15,
            'headers'  => ['Accept' => 'text/html,application/xhtml+xml'],
        ]));
        $this->browser->followRedirects(true);
        $this->browser->maxRedirects(5);
    }

    protected function tearDown(): void
    {
        $this->browser->restart(); // clears history + cookies
        parent::tearDown();
    }
}

Add a waitForServer() in setUpBeforeClass that polls the port — otherwise the first test fails with Connection refused on cold CI runners and the whole suite flaps.

2. Log in once per class, not once per test

The expensive part is the round-trip, not the assertion. Split your base class into "authenticated" and "guest" flavours and authenticate in setUpBeforeClass(), cloning the browser per test:

protected static HttpBrowser $authenticated;

public static function setUpBeforeClass(): void
{
    self::$authenticated = self::bootBrowser();
    self::login(self::$authenticated, '[email protected]', 'secret');
}

protected function setUp(): void
{
    parent::setUp();
    $this->browser = clone self::$authenticated; // shares CookieJar by reference? No — see gotchas
}

clone is shallow, so the CookieJar and History objects are shared and mutate across tests. That's usually what you want (session persists) but history assertions become order-dependent. If you assert on History, call $this->browser->restart() and re-authenticate, or just don't assert history in classes that use the shared clone.

3. Form workflows, including files and uncheckable widgets

$form = $crawler->selectButton('Create post')->form();

$form['title']             = 'Hello';
$form['tags']              = ['php', 'symfony'];   // multi-select / checkbox group
$form['published']->tick();                        // checkbox
$form['status']->select('draft');                  // radio / select
$form['cover']->upload(base_path('tests/fixtures/cover.jpg'));

$browser->submit($form);

For forms with required fields you don't care about, $form->disableValidation() lets the request go through anyway. For "what would this actually POST?", inspect $form->getPhpValues() and $form->getPhpFiles() — the single best debugging tool in the component.

4. API flows: jsonRequest() instead of hand-rolled headers

$browser->jsonRequest('POST', '/api/posts', ['title' => 'Hi', 'rating' => 4.0]);
$payload = json_decode($browser->getResponse()->getContent(), true);

jsonRequest() sets the JSON content type/accept headers and encodes with JSON_PRESERVE_ZERO_FRACTION, so 4.0 stays 4.0 and doesn't silently become 4 and fail a validation rule on the server. This matters in 7.2.4+/6.4.19+ — on older patches you'll chase a phantom validation error.

5. Asserting on redirects specifically

Auto-follow is on by default. When the redirect is the behaviour under test:

$browser->followRedirects(false);
$browser->request('POST', '/logout');

$response = $browser->getResponse();
$this->assertSame(302, $response->getStatusCode());
$this->assertSame('/login', $response->headers->get('Location'));

HttpBrowser::getResponse() returns an HttpFoundation Response, so getStatusCode(), headers->get(), and getContent() are all available. getInternalResponse() gives you the raw BrowserKit Response if you need it.

6. History navigation and its PHPUnit constraints

$browser->clickLink('Page 2');
$browser->clickLink('Page 3');
$browser->back();
$browser->forward();

$this->assertFalse($browser->getHistory()->isLastPage());  // 7.4/8.0+

Plus the matching constraints added in 7.4.0/8.0.0: BrowserHistoryIsOnFirstPage, BrowserHistoryIsOnLastPage — useful when pagination is the feature under test.

7. Scraping / non-test usage

The same class works outside PHPUnit. A console command that pulls data from a legacy portal:

$crawler = $browser->request('GET', 'https://legacy.internal/reports');
$browser->submitForm('Export', ['from' => '2025-01-01']);
file_put_contents($path, $browser->getResponse()->getContent());

No Node, no browser binary, no Selenium grid, MIT licence, reversibility in an afternoon — this is the strongest argument for the package in a Laravel stack.

8. The "KernelBrowser gap" — writing the glue (only if you need it)

If you want fast in-process simulation (no sockets, access to the container, RefreshDatabase, Queue::fake()), you must implement an AbstractBrowser subclass yourself. Sketch:

final class KernelBrowser extends AbstractBrowser
{
    public function __construct(private \Illuminate\Contracts\Http\Kernel $kernel) {}

    protected function doRequest(object $request): object
    {
        $illuminate = \Illuminate\Http\Request::create(
            $request->getUri(),
            $request->getMethod(),
            $request->getParameters(),
            $this->cookieJar->allValues($request->getUri()),
            $request->getFiles(),
            $this->serverParametersFrom($request),
            $request->getContent()
        );

        $response = $this->kernel->handle($illuminate);

        return new \Symfony\Component\BrowserKit\Response(
            $response->getContent(),
            $response->getStatusCode(),
            $response->headers->allPreserveCase()
        );
    }
    // ...getHistory(), getCookieJar(), getCrawler(), getInternalRequest/Response()
}

Budget it as a real artifact with its own tests, and version-lock it (see gotchas). For 90% of Laravel teams this is not worth it — $this->get()/$this->post()/$this->actingAs() already cover in-process journeys, and BrowserKit's value is precisely that it does not share a process.

9. Swap the client, keep the test code

Because everything routes through AbstractBrowser, you can replace HttpBrowser with Symfony\Component\Panther\Client (real Chrome, JS execution) without rewriting a single test — same request(), selectButton(), submit(), getCrawler(). Start with HttpBrowser; escalate to Panther only for the handful of journeys that need JS, keeping the fast path for the rest.

Gotchas and Tips

No JavaScript. By design. Alpine, Livewire, Inertia/React, Turbo, Vue — none of it runs. You get the server-rendered HTML only. If a page's behaviour is decided client-side, BrowserKit will happily assert against a skeleton <div id="app"></div> and give you a false green. Detect these tests by their assertion on markup that only exists post-hydration; move them to Dusk/Panther/Playwright.

It is not a replacement for Laravel's HTTP test helpers — and the difference is not cosmetic. BrowserKit talks over TCP to a separate process, which means:

  • RefreshDatabase doesn't reach it (different DB connection lifetime, different process).
  • Queue::fake(), Mail::fake(), Event::fake(), Http::fake(), Storage::fake(), actingAs() — all unreachable. The app is a black box.
  • You test against a real mailer (Mailpit/MailHog), real queue driver (sync or a drained queue), and a real object store (MinIO/bucket in CI).
  • State cleanup goes through an endpoint, an artisan call, or a DB truncation between tests — and it will be slower.

Reach for BrowserKit when the contract with the outside world is the thing under test: cookies, redirects, session persistence across requests, third-party services, a legacy app you don't own, or your own service running in docker-compose.

Do not build a mock-heavy unit suite on top of it. Every assertion costs a socket. Use it for a small number of high-value journeys (login, checkout, onboarding, OAuth callback), not for exhaustive validation-rule coverage — those belong in feature tests with $this->post().

Cookie jar is in-memory and per-instance. restart() clears cookies and history. $browser->getCookieJar()->clear() clears only cookies. There is no disk persistence — a new HttpBrowser is a new anonymous session. This bites hard in Pest, where each it() runs in a fresh closure and a statically-cached browser may leak state between files.

clone is shallow. Cloning a browser shares the same CookieJar and History objects. Mutations in one test are visible in the next. Construct a fresh browser whenever isolation matters, and only share intentionally.

Absolute URIs, or set base_uri. Relative URIs throw unless the underlying HttpClient has a base_uri. Note RFC 3986 resolution: 'api/posts' (no leading slash) resolves relative to the current path and will surprise you after a redirect. Always lead with /.

Version-pin for the bug fixes you'll actually hit:

Symptom Fixed in
Session cookie containing + decodes wrong 7.4.17 / 8.1.9
Wrong cookie chosen when several match (nested paths/domains) 7.4.17 / 8.1.9
Cookie expiry as int rejected 6.4.31 / 7.3.9 / 8.0.3
History broken with query-only URLs (?page=2 without a path segment) 6.4.28 / 7.3.6
Empty file inputs break form submission 6.4.19 / 7.2.4
Float 4.0 serialized as 4 in jsonRequest() 6.4.19 / 7.2.4
Typed-property access before init 7.1.6

Symfony 8 is a breaking upgrade for custom subclasses. Enforced return types (#60697) mean a KernelBrowser written against 7.x may fail to compile on 8.x because overridden methods now require compatible return type declarations. If you write glue, pin the Symfony major and add the return types.

CSRF tokens are session-bound; tokens in a reused DOM are not. Fetching /login, then calling restart(), then submitting the previously-extracted form gives you a 419. Always re-fetch the form after any session reset.

Timeouts and retries are your responsibility. Set timeout and max_duration on the HttpClient. For flaky CI, wrap in RetryableHttpClient (symfony/http-client ships it) rather than sprinkling sleep() in setUp().

Parallel testing will collide. Paratest/Pest --parallel workers all hit the same server and the same database. Either tag these tests and run them serially (->group('e2e') with --exclude-group), or spin one server + one DB per worker. Mixing sessions across workers produces failures that only appear on CI.

4xx/5xx handling isn't obvious — verify it. HttpBrowser catches transport exceptions and surfaces them as HttpException, but HTTP error statuses behave per your HttpClient configuration. The reliable habit: assert on $browser->getResponse()->getStatusCode() rather than relying on an exception being thrown, and wrap $browser->request() in try/catch for ClientExceptionInterface/ServerExceptionInterface when a 4xx is an expected outcome (e.g., testing a 403).

Debugging toolkit. In order of usefulness:

dump($crawler->html());                         // what did I actually get?
dump($browser->getCookieJar()->allValues($uri)); // session state
dump($browser->getRequest());                    // what did I send?
dump($browser->getResponse()->getStatusCode());  // what came back?
dump($form->getPhpValues());                     // what will the POST contain?

Add a logging decorator (LoggerInterface on HttpClient, or TraceableHttpClient if you're inside a Symfony app) to see the wire-level requests — invaluable when a redirect chain silently terminates somewhere unexpected.

Extension points worth knowing: AbstractBrowser (swap the transport; Panther is the reference example), CookieJar::updateFromSetCookie() (custom persistence across test classes), DomCrawler\Crawler::add() (crawl HTML you produced elsewhere), and Form::disableValidation() / getPhpValues() for inspecting and bypassing form semantics.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource