Skip to content
Package Toolkit
GitHub repository

Testing

Test a package built with the toolkit using Orchestra Testbench and Pest — including the static-state reset that makes provider tests repeatable.

On this page 10

A package built with the toolkit is tested the way any Laravel package is: inside a bootstrapped application supplied by Orchestra Testbench. What is specific to the toolkit is that most of what you want to assert happens during register() and boot() — which means the provider has to be configured before the application boots, and any static state it touched has to be cleaned up afterwards.

Setup

composer require --dev orchestra/testbench pestphp/pest pestphp/pest-plugin-laravel
Laravel Testbench Pest
12.x ^10.0 ^3.1
13.x ^11.0 ^4.0
tests/TestCase.phpphp
namespace Acme\Blog\Tests;
 
use Acme\Blog\BlogServiceProvider;
use Illuminate\Foundation\Application;
 
class TestCase extends \Orchestra\Testbench\TestCase
{
protected function getPackageProviders($app): array
{
return [BlogServiceProvider::class];
}
 
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('database.default', 'sqlite');
$app['config']->set('database.connections.sqlite', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
}
tests/Pest.phpphp
uses(Acme\Blog\Tests\TestCase::class)->in('Feature', 'Unit');

Testing the resources

Once the provider is registered, everything it wired up is assertable through the framework's own APIs:

test('config is merged', function () {
expect(config('blog.per_page'))->toBe(15);
});
 
test('views are registered', function () {
expect(view()->exists('blog::post'))->toBeTrue();
});
 
test('translations resolve', function () {
expect(trans('blog::messages.title'))->toBe('Blog');
});
 
test('routes are loaded', function () {
$this->get('/api/blog/posts')->assertOk();
});
 
test('the command is registered', function () {
$this->artisan('blog:prune', ['--dry-run' => true])->assertSuccessful();
});
 
test('the middleware alias exists', function () {
expect(app(\Illuminate\Routing\Router::class)->getMiddleware())
->toHaveKey('blog.author');
});

Testing publishing

use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
 
test('the config group is registered', function () {
expect(ServiceProvider::pathsToPublish(null, 'blog::config'))->not->toBeEmpty();
});
 
test('config publishes to the config directory', function () {
$this->artisan('vendor:publish --tag=blog::config')->assertExitCode(0);
 
expect(config_path('blog.php'))->toBeFile();
});
 
afterEach(function () {
File::delete(config_path('blog.php'));
});

Publishing writes to a shared workbench

Testbench's skeleton application is on disk and shared by every test in the run. Publishing in one test is visible to the next unless you clean up. Delete what you published in an afterEach, and be specific about what you delete.

Varying the configuration per test

The awkward part of testing a package provider is that configure() is fixed at the class level, while each test wants a different configuration. The pattern the toolkit uses on itself is a provider whose configure() delegates to a static closure:

tests/TestServiceProvider.phpphp
namespace Acme\Blog\Tests;
 
use Closure;
use NyonCode\LaravelPackageToolkit\PackageServiceProvider;
use NyonCode\LaravelPackageToolkit\Packager;
 
class TestServiceProvider extends PackageServiceProvider
{
public static ?Closure $configureUsing = null;
 
public static ?Closure $aboutDataUsing = null;
 
public function configure(Packager $packager): void
{
(self::$configureUsing ?? fn (Packager $packager) => null)($packager);
}
 
public function aboutData(): array
{
return (self::$aboutDataUsing ?? fn () => [])();
}
}
tests/PackageTestCase.phpphp
abstract class PackageTestCase extends TestCase
{
abstract protected function configure(Packager $packager): void;
 
protected function setUp(): void
{
$this->resetServiceProviderState();
 
TestServiceProvider::$configureUsing = fn (Packager $packager) => $this->configure($packager);
TestServiceProvider::$aboutDataUsing = null;
 
parent::setUp(); // ← the application boots here, with the closure in place
}
 
protected function getPackageProviders($app): array
{
return [TestServiceProvider::class];
}
}

The ordering is the whole trick: the closure is assigned before parent::setUp(), because that is what boots the application and runs register().

With Pest, each test file supplies its own configuration through a trait:

tests/Feature/ViewsTest.phpphp
trait ConfiguresViews
{
public function configure(Packager $packager): void
{
$packager->name('Blog')->hasViews();
}
}
 
uses(ConfiguresViews::class);
 
test('views are registered', function () {
expect(view()->exists('blog::post'))->toBeTrue();
});

Resetting static state

Laravel's ServiceProvider keeps its publish map, publish groups and optimize commands in static properties, and AboutCommand keeps its sections the same way. None of that is reset between tests in the same process, so the fifth test in a run sees the publish groups of the first four.

tests/PackageTestCase.phpphp
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Support\ServiceProvider;
use NyonCode\LaravelPackageToolkit\PackageServiceProvider;
use ReflectionClass;
 
protected function resetServiceProviderState(): void
{
$this->resetStaticProperty(ServiceProvider::class, 'publishes', []);
$this->resetStaticProperty(ServiceProvider::class, 'publishGroups', []);
$this->resetStaticProperty(ServiceProvider::class, 'publishableMigrationPaths', []);
$this->resetStaticProperty(ServiceProvider::class, 'optimizeCommands', []);
$this->resetStaticProperty(ServiceProvider::class, 'optimizeClearCommands', []);
$this->resetStaticProperty(PackageServiceProvider::class, 'isPackageAboutRegistered', false);
 
AboutCommand::flushState();
}
 
private function resetStaticProperty(string $class, string $property, mixed $value): void
{
$reflection = new ReflectionClass($class);
 
// Not every property exists in every supported Laravel version.
if (! $reflection->hasProperty($property)) {
return;
}
 
$propertyReflection = $reflection->getProperty($property);
 
if (! $propertyReflection->isStatic()) {
return;
}
 
$propertyReflection->setValue(null, $value);
}

The list is the part worth reading; resetStaticProperty() is folded above because it is the same reflection boilerplate every package writes once. Expand it for the one detail that is not boilerplate — the hasProperty() guard. These are framework internals, and they are not identical across Laravel 12 and 13. Skipping a property that does not exist keeps the suite green on both.

isPackageAboutRegistered is the toolkit's own flag — it stops the "Laravel Package Toolkit" section being registered more than once per process, which without a reset means only the first test in a run sees it.

Cleaning the filesystem

The asset mirror and vendor:publish both write into the workbench's public/, config/ and database/ directories:

protected function clear(): void
{
File::deleteDirectory(public_path('vendor/blog'));
 
File::delete(config_path('blog.php'));
 
foreach (File::files(database_path('migrations')) as $migration) {
if (str_contains($migration->getFilename(), 'blog')) {
@unlink($migration->getPathname());
}
}
}

Timeless migrations need the fuzzy match: they are published with a generated timestamp prefix, so the filename you clean up is not the filename you shipped.

Testing the asset mirror

use NyonCode\LaravelPackageToolkit\Support\PublishedAssets;
 
beforeEach(fn () => File::deleteDirectory(public_path('vendor/blog')));
 
test('resolving one asset mirrors the whole directory', function () {
$shipped = __DIR__.'/../../dist/css/blog.css';
 
$url = app(PublishedAssets::class)->url('blog', $shipped);
 
expect(public_path('vendor/blog/css/blog.css'))->toBeFile()
->and(public_path('vendor/blog/js/blog.js'))->toBeFile()
->and($url)->toContain('?id=');
});
 
test('flush lets the mirror run again', function () {
$shipped = __DIR__.'/../../dist/css/blog.css';
 
app(PublishedAssets::class)->url('blog', $shipped);
 
unlink(public_path('vendor/blog/css/blog.css'));
clearstatcache();
 
app(PublishedAssets::class)->flush();
 
expect(app(PublishedAssets::class)->url('blog', $shipped))->not->toBeNull()
->and(public_path('vendor/blog/css/blog.css'))->toBeFile();
});

clearstatcache() is not optional here. PHP caches stat results within a request, and the mirror decides what to copy from filemtime().

Testing the packager on its own

Some behaviour needs no application at all:

use NyonCode\LaravelPackageToolkit\Packager;
 
test('the short name is derived from the name', function () {
expect((new Packager)->name('My Awesome Package')->shortName())
->toBe('my-awesome-package');
});
 
test('an empty name is rejected', function () {
expect(fn () => (new Packager)->name(' '))
->toThrow(InvalidArgumentException::class);
});
 
test('a non-kebab short name is rejected', function () {
expect(fn () => (new Packager)->name('Blog')->hasShortName('My_Blog'))
->toThrow(InvalidArgumentException::class);
});
 
test('a timeless migration is detected', function () {
$packager = new Packager;
 
expect($packager->hasDatePrefix('create_posts_table.php'))->toBeFalse()
->and($packager->hasDatePrefix('2025_01_01_000000_create_posts_table.php'))->toBeTrue();
});

Running migrations in tests

protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}

Or, if your package calls canLoadMigrations(), they are already registered and RefreshDatabase picks them up.

CI

The toolkit's own matrix is a reasonable template — every supported PHP against every supported Laravel, at both dependency extremes, on Linux and Windows:

.github/workflows/tests.ymlyaml
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
php: ['8.2', '8.3', '8.4', '8.5']
laravel: ['12.*', '13.*']
stability: [prefer-lowest, prefer-stable]
exclude:
- laravel: '13.*'
php: '8.2'

prefer-lowest is the one that earns its place: it is what catches a package that quietly depends on a feature added in a patch release of a dependency it claims to support from ^12.0.

Windows matters more than it looks for a package that resolves paths. Every path in the toolkit goes through normalisation for that reason, and a package built on it should verify the same thing.