laragear/meta-testing
Laragear Meta Testing provides testing helpers and assertions for Laravel applications that use metadata. Verify metadata creation, updates, and retrieval with concise, readable tests, keeping metadata behavior reliable without repetitive boilerplate.
laragear/meta-testing is a dev-only package: it gives your package's test suite a pre-built base TestCase on top of Orchestra Testbench, plus assertion helpers for the things package authors test over and over (service provider bindings, merged config, migrations, routes, commands, views, translations, publishable assets).
Install it as a dev dependency in the package you are building, not in an application:
composer require laragear/meta-testing --dev
Then point your test suite at its base class. The minimal working setup is a tests/TestCase.php that extends the package's test case and declares your package provider:
<?php
namespace Acme\MyPackage\Tests;
use Acme\MyPackage\MyPackageServiceProvider;
use Laragear\MetaTesting\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function getPackageProviders($app): array
{
return [MyPackageServiceProvider::class];
}
}
Where to look first, in this order:
vendor/laragear/meta-testing/src/TestCase.php — the base class tells you exactly which traits are composed and which hooks (defineEnvironment, defineRoutes, getPackageProviders) are expected.vendor/laragear/meta-testing/src/Concerns/ (or equivalent) — the trait list is the real feature inventory. Read the method names before reading the README; it is faster and always accurate for the installed version.tests/ directory on GitHub — it is by definition a working example of the package testing itself.composer.json — this determines which Laravel versions you can claim support for.CHANGELOG.md / the GitHub releases page — see the version note below; assertion helpers are the part that changes most between minor releases.First use case — the highest-value test you can write on day one is a provider smoke test, because almost every package regression starts there:
public function test_service_provider_registers_the_client(): void
{
$this->assertTrue($this->app->bound(Client::class));
$this->assertInstanceOf(Client::class, $this->app->make(Client::class));
}
If that passes, your provider boots, your config is readable, and the container is wired — which means every subsequent test failure is about behaviour, not scaffolding.
Version note (v4.3.0): this release adds improved validation/assertion mechanisms and removes the @internal tag from the traits, which is the more consequential change for you. Previously trait composition was, strictly speaking, an unsupported internal detail; from 4.3.0 the traits are public API you can legitimately use in your own base class. If you were avoiding direct trait use or copy-pasting helpers to stay off internals, you can stop. Conversely, if you were relying on undocumented trait internals, expect those to be fair game for future changes — the traits are supported for composition, not as a stability guarantee for every protected member.
The package is designed so you opt into capabilities rather than inherit a monolith. If your base TestCase already extends the provided one, you generally get everything; if you want a lighter suite, wrap it yourself:
abstract class TestCase extends \Laragear\MetaTesting\TestCase
{
use \Laragear\MetaTesting\Concerns\InteractsWithMigrations;
use \Laragear\MetaTesting\Concerns\InteractsWithRoutes;
}
Since v4.3.0 dropped the @internal tags, this composition is a supported pattern rather than a workaround. The practical rule stays the same: keep one abstract TestCase per package, put every trait there, and never use the traits directly in test classes. That gives you a single file to update when Testbench or the meta-testing package changes.
The temptation with a package like this is to assert that register() was called or that a specific mergeConfigFrom path was hit. Assert the observable outcome instead:
public function test_config_is_merged_with_package_defaults(): void
{
$this->assertSame('acme', config('my-package.driver'));
$this->assertTrue(config('my-package.features.caching'));
}
Merged config is one of the most common silent breakages when a package is refactored — a mergeConfigFrom call removed or a key renamed — and it costs one line to guard.
For packages that ship migrations, prefer SQLite in memory and let the base class handle migration loading. The pattern is RefreshDatabase plus a getEnvironmentSetUp that sets the connection, so no developer machine state leaks in:
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
Then assert both directions — the migration creates what it should, and the down migration reverses it:
public function test_migration_up_and_down(): void
{
$this->assertTrue(Schema::hasTable('acme_records'));
$this->artisan('migrate:rollback')->assertSuccessful();
$this->assertFalse(Schema::hasTable('acme_records'));
}
For packages that register routes, the meta-testing helpers let you hit endpoints through the normal HTTP kernel rather than mocking the router:
public function test_webhook_endpoint_is_reachable(): void
{
$this->postJson('/acme/webhook', ['event' => 'ping'])
->assertOk()
->assertJson(['received' => true]);
}
For console packages, the same Testbench artisan() API applies — assert exit codes and output, since a command that returns 0 but prints an error is a real and common bug:
$this->artisan('acme:sync')
->expectsOutputToContain('Synced 3 records')
->assertSuccessful();
Anything you publishes() should have a test that the publish group name and destination paths are correct, because these are public API that users put in their deploy scripts. v4.3.0's improved validation mechanisms are worth checking here first — read the trait methods in src/Concerns/ for the current publish/config assertions before hand-rolling. If no helper covers your case, fall back to ServiceProvider::pathsToPublish():
public function test_config_is_publishable(): void
{
$paths = ServiceProvider::pathsToPublish(MyPackageServiceProvider::class, 'my-package-config');
$this->assertArrayHasKey(__DIR__.'/../config/my-package.php', $paths);
}
If the suite is Pest-based, wrap the base class in tests/Pest.php once and keep the same provider declaration:
uses(Acme\MyPackage\Tests\TestCase::class)->in('Feature', 'Unit');
Don't mix — pick PHPUnit TestCase inheritance or Pest binding for the whole package, otherwise RefreshDatabase and provider bootstrapping get applied inconsistently between directories.
Because the whole point is testing across Laravel versions, drive the matrix off orchestra/testbench majors rather than Laravel majors — that's the actual compatibility axis. Keep one job on the lowest supported Testbench and one on the highest; the middle versions rarely fail independently. When v4.3.0-style releases add assertions, run the lowest job first: a new helper that assumes a newer Testbench API will fail there and nowhere else.
This package has a very small user base (single-digit stars, zero dependents). Treat it as a convenience layer, not critical infrastructure. Pin it to a minor version in composer.json, and if it goes unmaintained, you can delete it in an afternoon by inlining its base TestCase and traits into your own tests/ directory — they are thin wrappers over Testbench. Don't build your suite so that this package is load-bearing for production code.
Version matrix is the #1 failure source. The meta-testing release must match the Testbench major you're testing against, which must match the Laravel major. A mismatch typically surfaces as a fatal Class not found or a setUp signature error before any test body runs. When you bump Laravel support, bump Testbench and meta-testing in the same commit and re-run the lowest-version CI job first.
Trait method collisions. If you also use Testbench traits or your own helpers, setUpTraits() resolution order matters and PHP will fatal on duplicate methods. Symptom is usually "trait method not applied because of collision" or a method silently doing the wrong thing. Fix by aliasing in your base class rather than by removing traits from individual tests. Note that v4.3.0's improved validation mechanisms may add or rename assertion methods inside those traits — if you aliased one, re-check the alias after upgrading, and diff the trait list before assuming your composition still covers everything.
getEnvironmentSetUp vs defineEnvironment. These are different hooks on different versions of Testbench. If your config isn't being applied, check which one the installed Testbench actually calls — a correct-looking override in the wrong hook fails silently, and you'll spend an hour debugging a test that's reading the package's default config.
Config caching inside a single process. If any test calls config([...]) at runtime, that mutation persists into later tests in the same class unless the base class refreshes the app per test. Always mutate through $this->app['config'] inside defineEnvironment/getEnvironmentSetUp for suite-wide settings, and use $this->app['config']->set() inside a test body only for that test.
In-memory SQLite hides real migration bugs. It won't catch index name length limits (MySQL's 64-char cap), json column behaviour, or ALTER TABLE limitations. If your package ships migrations, keep at least one CI job against MySQL or Postgres. The meta-testing helpers make SQLite easy, which makes it easy to forget this.
Assertions on the provider can be too shallow. $this->assertTrue($this->app->bound(Foo::class)) passes even if the binding resolves to a broken object. Pair every bound()/assertRegistered() with a make() and a behaviour assertion on the resolved instance — doubly so for the newer validation helpers, where a passing assertion only means "the shape matched", not "the thing works".
Debugging a failing boot. When a test dies in setUp, you get very little signal. Reduce to a single empty test method, then add dump($this->app->getLoadedProviders()) and dump($this->app->getBindings()) to see what the provider actually did. Running vendor/bin/phpunit --filter=that_one_test --debug also shows the exception Testbench swallows during bootstrap. If a failure appears only after bumping to 4.3.0, suspect a validation helper tightening its checks rather than your code — bisect by temporarily swapping the assertion for a plain assertTrue/assertSame and see whether the underlying value is actually wrong.
Publishing tests assert paths, not copies. pathsToPublish() tells you the mapping; it does not prove vendor:publish works on a real filesystem. If publishing correctness matters to users, add one integration test that calls $this->artisan('vendor:publish', ['--tag' => '...']) and asserts the file landed in the temp app skeleton.
Extension points. With the @internal tags gone as of v4.3.0, the intended way to grow the suite is clearer than ever: add your own custom assertions on top of the base class — an assertPackageConfigHas() or assertMigrationRan() helper living in your tests/Concerns/ — rather than subclassing the vendor class deeper. Keep your helpers in your own namespace so a meta-testing upgrade can't break them. The traits are now supported for composition, but individual protected methods aren't a frozen contract; keep the vendor base class as a thin, disposable layer you could inline in a day.
How can I help you explore Laravel packages today?