behat/web-api-extension
Abandoned Behat 3 extension (never had a stable release) that provides testing for JSON APIs. It won't receive further updates, so consider alternatives for new projects; kept here for reference and legacy suites.
First, the honest framing for a Laravel developer: this is not a Laravel package. It is an abandoned Behat 3 extension that never had a stable release, has zero dependents, and receives no updates. In a Laravel codebase the default day-to-day answer is to use Laravel's own HTTP test helpers ($this->getJson(), assertJson(), assertStatus()) with PHPUnit or Pest. Read this section only if you have a legacy Behat suite to keep alive, or you are explicitly evaluating whether to adopt BDD-style API specs.
Compatibility audit before install. The risk with a Behat 3–era extension in a modern Laravel app is not the extension itself — it's the transitive graph. It will pull behat/behat:^3, which in turn constrains old symfony/* components, and Laravel already pins its own Symfony versions for Console, HttpFoundation, Finder, etc. Add a dev-only constraint and observe the resolver:
composer require --dev --dry-run behat/behat:^3.0 behat/web-api-extension:dev-master
If composer reports conflicts against laravel/framework's Symfony constraints, stop here. Resolving those by force-pinning Symfony versions in a Laravel app is how you break artisan after a routine composer update. Do not do it.
If the graph resolves, install and pin a commit. There is no stable tag, so dev-master will move:
composer require --dev behat/behat:^3.0 behat/web-api-extension:dev-master
# then pin the resolved commit in composer.lock and treat it as frozen
Read doc/index.rst, not the README. The README is a deprecation notice. The authoritative step vocabulary, config keys, and registered services live in that RST file and in src/. The Gherkin in any tutorial (including this one) is illustrative — verify the exact step phrasing against the bundled context class before writing feature files.
Register it and run one happy-path scenario. Create behat.yml at the project root, register the bundled context, and point base_url at a running instance of your Laravel app (php artisan serve on a spare port, or a Sail/docker compose service):
default:
suites:
api:
contexts:
- Behat\WebApiExtension\Context\WebApiContext
extensions:
Behat\WebApiExtension\ServiceContainer\WebApiExtension:
base_url: 'http://127.0.0.1:8001'
Then a single scenario against an existing route:
Feature: Users API
Scenario: Fetch an existing user
When I send a GET request to "/api/users/1"
Then the response status code should be 200
And the response should be JSON
Run vendor/bin/behat --no-colors. The decision gate: if getting this one scenario green requires you to debug the extension, its HTTP client, or the Composer graph, abandon the trial. A custom Behat context wrapping Guzzle is roughly 60 lines and you own it; a maintained alternative is better still.
Keep the extension as a transport layer only. The single most important pattern: let it send requests, and put every domain assertion in your own context. This makes the eventual migration a mechanical port rather than a rewrite.
Subclass the bundled context, register only the subclass. Registering both WebApiContext and your subclass produces ambiguous-step failures. Put auth, fixture setup, and domain steps in a child class:
namespace Tests\Behat;
use Behat\WebApiExtension\Context\WebApiContext;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
class ApiContext extends WebApiContext
{
/** @BeforeScenario */
public function authenticate(BeforeScenarioScope $scope): void
{
// Obtain a token via Laravel's own test users, then attach it
// to the underlying HTTP client/request builder exposed by the parent.
}
}
Check the parent's actual property/method names in src/ — the extension is undocumented enough that you should not guess an accessor.
Drive the app from a real HTTP boundary, not the test harness. Because this is black-box, Laravel-specific test affordances don't apply: no RefreshDatabase (that trait lives inside the PHPUnit/Illuminate\Foundation\Testing harness), no facade fakes, no container swaps, no actingAs(). Instead:
php artisan migrate:fresh --seed --env=testing), invoked in a @BeforeSuite shell step or your CI script..env.testing so the Behat process and the serving process agree on state.Environment-specific base URLs, not committed secrets. Behat 3 predates reliable env interpolation in behat.yml. The robust pattern is a CI step that templates behat.yml from environment variables, or a per-environment profile:
ci:
extensions:
Behat\WebApiExtension\ServiceContainer\WebApiExtension:
base_url: 'http://api:8000'
Run with vendor/bin/behat --profile=ci, and keep the profile file out of version control.
Scenario Outlines for contract matrices. This is where BDD genuinely pays for API work — status code and response-shape permutations as tables rather than duplicated scenarios. It also keeps your specification value intact if you later re-home these cases into Pest datasets.
Chain deeper payload validation in a custom step. Do not expect the extension to do JSON Schema validation. Add justinrainbow/json-schema (or similar) and write one step that validates the last response body against a schema file. The extension's job ends at "here is the JSON."
Put the exit plan in the repo. A short docs/adr/ note recording why an abandoned dependency is present, who owns it, and the target replacement. This is the difference between a controlled legacy decision and a slow-motion incident.
Abandoned, never stable, zero dependents. No security patches, no PHP 8.x compatibility guarantees, no Behat 4 path, no community pressure to fix anything. If you keep it, fork it into your VCS or vendor the source so a future composer change can't silently remove it.
Symfony constraint collisions are the practical blocker in Laravel. Laravel pins Symfony components for Console, HttpFoundation, Finder, and more. A Behat 3 graph will want older ones. Pin the entire graph — laravel/framework included — and run composer update --dry-run before any routine dependency bump. If a routine Laravel minor upgrade breaks the test suite, you have the wrong tool.
Abandonment warnings in output. composer install prints an abandoned-package notice. Filter it deliberately in CI logs; don't blanket-suppress composer warnings or you'll miss real advisories.
Config key ambiguity. Behat 3 extensions are addressable by FQCN or short alias. If behat.yml fails validation, check getConfigKey() on the extension class and mirror it exactly, and verify the indentation under extensions: — a misnested block silently produces a "no extension found" error that looks like a config key problem.
Trailing-slash joining bugs. Old HTTP wrappers concatenate naively. Test base_url with and without a trailing slash, and paths with and without a leading /. /api/users against http://host/api can produce http://host/api/api/users or //api/users.
TLS, proxies, and self-signed local certs. If the underlying client verifies strictly, a Sail/mkcert local stack fails with an opaque connection error. Find the client config option or override the client service via behat.yml's services section — do not disable verification globally.
Debugging failing steps. vendor/bin/behat -vvv --no-colors, plus a temporary @AfterScenario hook that dumps the last response status, headers, and body. With documentation this thin, the response dump is the fastest way to learn what the extension actually sent.
Ambiguous steps from double registration. One context per suite. If you see "step is ambiguous," you registered both the parent and your subclass.
Limited extension points. You get service overrides via Behat's container and context subclassing — no plugin hooks, no PSR-18 client injection, no modern middleware story. Design custom contexts so their internals are swappable.
Keep Gherkin portable. Avoid extension-specific phrasing where a plain-English step of your own would do. When you migrate (likely, and soon), feature files should survive unchanged and only the context implementation should need rewriting.
The Laravel-native alternative is the yardstick. Before extending this package, write the same scenario in Pest:
it('returns an existing user', function () {
$this->getJson('/api/users/1')
->assertOk()
->assertJsonPath('id', 1);
});
If that covers your need — and for a Laravel API it usually does — the abandoned Behat extension buys you nothing but a maintenance liability.
How can I help you explore Laravel packages today?