bambamboole / extended-testbench
Extends Orchestra Testbench for Laravel package development - makes Laravel Boost work out of the box.
Requires
- php: ^8.4
- illuminate/support: ^13.0
- laravel/boost: ^2.4
- laravel/prompts: ^0.3
- orchestra/testbench: ^11.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.16
- pestphp/pest: ^5.0
- pestphp/pest-plugin-phpstan: ^5.0
- rector/rector: ^2.0
This package is auto-updated.
Last update: 2026-08-03 10:15:09 UTC
README
Use Laravel Boost in Laravel package development with Orchestra Testbench — zero configuration.
The problem
Boost assumes it runs inside a full Laravel app and resolves everything via base_path().
Under Testbench, base_path() is the vendor skeleton (vendor/orchestra/testbench-core/laravel),
so boost:install / boost:update read config from and write skills, guidelines state, and
boost.json into your vendor/ directory, and Roster detects zero packages.
Upstream declined package support (laravel/boost#746, laravel/boost#855) and recommended
an additional package — this is that package.
Install
composer require --dev bambamboole/extended-testbench
That's it — this is the only dev dependency you need. orchestra/testbench ^11 comes with it, and
the provider is auto-discovered by testbench package:discover.
Requiring testbench 11 means Laravel 13. If your package still supports Laravel 11 or 12, stay on
your own orchestra/testbench constraint and don't install this bridge yet.
Use
vendor/bin/testbench boost:install vendor/bin/testbench boost:update --no-interaction
Everything lands in your package repo: CLAUDE.md / AGENTS.md, boost.json, .ai/,
agent skill directories. Roster scans your real composer.lock, so package-specific
guidelines, search-docs, and application-info work. An artisan entrypoint — a one-line
shim that requires vendor/bin/testbench — is created so the generated MCP config
(php artisan boost:mcp) works verbatim. Commit it; Boost hardcodes the artisan script name
in its MCP config, its tool subprocesses, and its guideline text.
Scaffold a package
vendor/bin/testbench package:init
Installs Pest 5 with pest-plugin-laravel (allowing pestphp/pest-plugin in
config.allow-plugins first, so the non-interactive composer require is not refused) and writes
the artisan entrypoint (executable, it ships a shebang), .gitattributes
(development-only files marked export-ignore so they don't ship in the dist archive),
.github/workflows/ci.yml (a PHP 8.4/8.5 × highest/lowest dependency matrix — the lowest leg
runs only the test suite, since lint and static analysis are pinned to the tool versions you
develop with — then package:init --check as a drift gate),
phpunit.xml.dist (sqlite :memory:, plus the Testbench skeleton's APP_KEY — the skeleton keeps
that key in a .env that the package:purge-skeleton hook deletes on every autoload dump, so
pinning it here is what keeps a cold suite from throwing MissingAppKeyException),
tests/TestCase.php, tests/Pest.php, tests/ArchTest.php (an Arch suite forbidding debug
statements and pinning declare(strict_types=1) across the package namespace) and testbench.yaml
when they are missing, plus the .gitignore entries for everything it and Boost generate. Then it asks about:
- a workbench app — adds the
workbench:block totestbench.yamland hands the namespaces, directories andautoload-deventries to Testbench's ownworkbench:devtool - browser tests —
pest-plugin-browser, a dummy test, atests/BrowserTestCase.phpthat fails fast when the workbench's Vite build is missing or stale (skipped for packages with novite.config), and aBrowsersuite intests/Pest.phpmapped to it instead of the baseTestCase. The generated workflow gets a dedicatedbrowserjob — one PHP version, Playwright Chromium installed once,npm ci/npm run buildfirst when the package has apackage.json— while the matrix jobs runpest --exclude-testsuite=Browserand never need a browser - PHPStan — Larastan + the Pest PHPStan extension,
phpstan.neon.dist(level6unless you pass--phpstan-level), astanscript - Rector —
rector.phpplus arefactorscript - Pint —
pint.jsonplus alintscript
Every section can be answered on the command line, so an agent or a CI job can drive it:
vendor/bin/testbench package:init --workbench --browser --phpstan-level=8 --no-rector vendor/bin/testbench package:init --defaults
--no-workbench, --no-browser, --no-playwright, --no-phpstan, --no-rector and --no-pint
answer the other way. Without a terminal and without any of these flags the command refuses to run
rather than guessing — pass --defaults to take every default.
Before adopting the scaffold in a package that already has its own setup, ask where the two diverge:
vendor/bin/testbench package:init --check
--check writes nothing at all — no files, no composer require, no composer.json edits, none of
the subprocesses — and reports every file, package, composer script and .gitignore entry as ok,
missing or differs, printing a unified diff for each generated config whose body has drifted. It
exits non-zero when anything diverges, so it works as a CI guard. It answers the section questions
itself rather than prompting; section flags still narrow what it looks at. Files that hold
hand-written code (tests/TestCase.php, tests/Pest.php, testbench.yaml, artisan,
.gitattributes, the CI workflow) are only checked for existence — comparing their bodies against
the stub would report every package that has ever edited its own TestCase. Comparisons ignore
whitespace, so reformatting a generated config — wrapping withPaths([...]), reindenting the neon —
is not drift; reordering its keys still is. An array script that carries extra entries around the
scaffold's own — a post-install-cmd that prepends your git-hooks installer to @boost:refresh —
is not drift either; only a missing scaffold entry counts.
Divergence you have decided on is baselined in composer.json, so the non-zero exit stays usable as
a CI gate:
{
"extra": {
"extended-testbench": {
"check-ignore": ["tests/Unit", "phpunit.xml.dist"]
}
}
}
Each entry is a row label from the table. Ignored rows stay visible as ignored (missing) or
ignored (differs) — they just stop counting toward the exit code. An entry that ignored nothing —
it matches no row, or only rows that are already ok — gets a warning instead of silence, so a
resolved divergence or a renamed artifact label cannot rot the baseline; the warning never affects
the exit code.
Adopting what the check found is what --force is for:
vendor/bin/testbench package:init --defaults --force
Without it, an existing phpunit.xml.dist, phpstan.neon.dist, rector.php or pint.json is
reported as skipped (exists, --force to replace) rather than being overwritten, which is the right
default interactively but leaves a headless run with nothing written. --force covers only those
four generated configs; tests/TestCase.php, tests/Pest.php, testbench.yaml, artisan and
.gitattributes hold hand-written code and are never replaced, with or without the flag.
src, tests and, when present, workbench/app are the paths both PHPStan and Rector analyse;
PHPStan also adds database when the package has one. Rector's generated config skips the rule that
strips unused parameters from public methods, since Laravel resolves many signatures — policy
methods, middleware, listeners — by reflection, and stripping a parameter the body ignores breaks
them at runtime. Alongside the per-tool scripts you get a test script (the Browser suite excluded
once browser tests are accepted) and a test:browser script for that suite, preceded by
npm run build when the package has a package.json, plus a check script composed of whichever
tools you accepted, ending in @test, for CI and git hooks. It also wires the Testbench
post-autoload-dump hooks (package:purge-skeleton, package:discover) and a boost:refresh
script into post-install-cmd / post-update-cmd; boost:refresh reruns
boost:update --no-interaction on every local install or update, but no-ops in CI, before
vendor/bin/testbench exists, before Boost has been set up (boost.json present), or while a
package.json sits next to a missing node_modules — Boost discovers frontend packages (Inertia,
Echo) through the installed tree, so refreshing before npm install would silently drop their
skills from boost.json. Run npm install first, or rerun composer install after it. It never
fails the surrounding composer install/update even if that rerun does.
Existing files are never replaced without asking or --force, and the overwrite prompt shows a
unified diff first; a legacy phpunit.xml or phpstan.neon that would shadow the generated .dist
file only gets a warning, never rewritten automatically. An artisan that is a symlink to
vendor/bin/testbench — the widespread ln -s vendor/bin/testbench artisan recipe, which resolves
locally but breaks on a fresh clone and on Windows — is replaced with the committed shim; a symlink
pointing anywhere else is yours and only gets a warning. A composer script that already runs a tool
under its own name (analyse where we scaffold stan, including via ./vendor/bin/phpstan or
@php vendor/bin/phpstan) is reported as a collision rather than silently doubled up, but both are
kept: renaming your scripts and the CI that calls them is not this command's business. It finishes by running boost:install (or
boost:update --discover when Boost is already set up) and registering itself in boost.json's
packages key — Boost cannot discover a new third-party package non-interactively — so the
guidelines land in your CLAUDE.md / AGENTS.md without a second step.
What package:init scaffolds requires PHP 8.4+ and orchestra/testbench ^11: Pest 5 needs PHPUnit 13
and symfony/process ^8.1; testbench 10.x pulls in Laravel 12, which pins symfony/process to ^7.2,
so only testbench 11 (Laravel 13) resolves. pest-plugin-browser additionally requires ext-sockets.
Feature list
Internally, package:init runs its work as an ordered list of Features, each declaring the files,
packages and composer scripts it owns and the row those write to the result table above. A feature
whose artifacts do not depend on the run is a plain StaticFeature; the rest are named classes. The
list is internal — there is no public API to register a feature from outside this package — and its
order is fixed:
- the
artisanshim .gitattributes- the CI workflow
.gitignoreentriesPestFeature— the Pest/PHPUnit baselineWorkbenchFeature— the workbench appBrowserFeature— browser testsPlaywrightFeature— the Playwright browser installPhpstanFeature— PHPStanRectorFeature— RectorPintFeature— PintComposerScriptsFeature— thecheckscript and the Testbench/Boost composer hooksBoostFeature— Boost install/update and registering this package inboost.json
Shipped guidelines
This package publishes one Boost guideline covering comments, git and pull request conventions, and
the Testbench-specific facts agents get wrong (artisan is a shim, base_path() is the skeleton
and not your package). Boost discovers it automatically and composes it into your CLAUDE.md /
AGENTS.md under a bambamboole/extended-testbench rules heading.
package:init registers this package in boost.json's packages key and then reruns
boost:update so the guideline lands in the same run. If Boost is already installed and you would
rather not rerun package:init, pick it up once with:
vendor/bin/testbench boost:update --discover
That command only works at an interactive terminal. Boost gates discovery of new third-party
packages behind a multiselect and returns early without one, so --discover --no-interaction prints
updated successfully, exits 0, and composes nothing new. A headless caller has to add
"packages": ["bambamboole/extended-testbench"] to boost.json itself — or just run
package:init, which does exactly that.
Adding your own files to .ai/guidelines/ in your package extends the set — a same-named file does not
replace ours, both get composed into your CLAUDE.md / AGENTS.md. To drop ours entirely, deselect
this package when running boost:install, or remove it from the packages key in boost.json.
How it works
Only while a boost:* or mcp:* command runs under the Testbench CLI, the provider rebases
the booted app's base path to your package root (Application::setBasePath()), pinning the
skeleton's storage/config/database/bootstrap/lang/public paths first. Your test suite,
package:test, workbench:build, and serve never see any of this.
Notes
- Boost only registers its commands in a
localenvironment (or with debug on). If commands are missing, addAPP_ENV=localto theenvsection of yourtestbench.yaml. - Database-backed MCP tools run against the Testbench skeleton app — configure connections via your workbench setup as usual.
- The
artisanentrypoint is a plain PHP file, not a symlink, andpackage:initmarks it executable (0755) — a shebang without the executable bit means./artisanfails whilephp artisanworks, and--checkreports exactly that as drift. - Windows is not supported. Development happens on POSIX systems: the drift diff shells out to
diff, permissions are checked with POSIX semantics, and this repository tracks a symlink (.ai/guidelines/core.blade.php) that a checkout withoutcore.symlinksturns into a plain text filevendor/bin/pestfails loudly on. Use WSL. orchestra/testbench ^11is a hard requirement, not just what the suite is tested against: it sits inrequireso installing this package is all you need. That means PHP 8.4+ and Laravel 13 — and it applies to the whole dev environment, not just this bridge. A package that still supportsilluminate/* ^11 || ^12can depend on this one, but any CI job pinning testbench 10 or Laravel 11/12 will stop resolving once it is inrequire-dev. Keep it out until you are ready to drop those matrix legs.