Part 10 of 13 in the PHP Package Development series
In Part 8 and Part 9, we built a full-featured Laravel package with service providers, facades, Blade components, Artisan commands, routes, and middleware. But here's the uncomfortable question - how do you test all of that when your package doesn't have a Laravel application?
Regular PHP packages are straightforward to test. You instantiate a class, call a method, assert the result. Laravel packages are different. Your service provider needs the IoC container. Your facade needs the app to resolve it. Your Blade components need the view engine. Your commands need Artisan. Your routes need the HTTP kernel.
You need a Laravel application - but you don't want to maintain one in your package repository. That's where Orchestra Testbench comes in. It gives you a minimal Laravel application that boots up just for your tests, with everything you need and nothing you don't.
Let's set up a proper test suite for our jakovic/laravel-text-toolkit package.
Why Laravel Packages Need Special Testing
When you test a plain PHP package, you just autoload your classes and go. PHPUnit or Pest, a few assertions, done. But a Laravel package is deeply tied to the framework.
Think about what happens when your package boots:
- Your service provider registers bindings in the container
- Your facade resolves a class through the container
- Your Blade components register with the view factory
- Your config file merges with the app's config
- Your routes register with the router
- Your commands register with Artisan
- Your middleware plugs into the HTTP pipeline
Every one of these requires a running Laravel application. You can't just new TextToolkitServiceProvider() and hope for the best - it needs the full container, config repository, router, and all the other pieces Laravel provides.
Orchestra Testbench solves this by bootstrapping a minimal Laravel app in memory for each test. It's maintained by the same people who maintain the Laravel test infrastructure, and it's the standard tool for Laravel package testing. Almost every popular Laravel package uses it.
Installing Orchestra Testbench
Let's add Testbench and Pest to our package. If you're coming from Part 9, your jakovic/laravel-text-toolkit package already has a basic structure. Let's add the test dependencies.
cd laravel-text-toolkit
composer require --dev orchestra/testbench pest pestphp/pest-plugin-laravel
Testbench will pull in a full Laravel installation as a dependency - but only in your dev dependencies, so it won't bloat your users' projects.
The version of Testbench you install should match the Laravel versions you want to support. Here's the compatibility table:
- Testbench 9.x - Laravel 11.x
- Testbench 10.x - Laravel 12.x
If you want to support multiple Laravel versions, you can use a broader constraint:
{
"require-dev": {
"orchestra/testbench": "^9.0 || ^10.0",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0"
}
}
Now initialize Pest in your package:
./vendor/bin/pest --init
This creates a tests directory with a Pest.php file. Make sure your phpunit.xml (or phpunit.xml.dist) is set up at the package root:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
Setting Up the TestCase Base Class
The most important piece is your base TestCase class. This tells Testbench which service providers to load, what configuration to use, and how to set up the app environment for your tests.
Create tests/TestCase.php:
<?php
namespace Jakovic\LaravelTextToolkit\Tests;
use Jakovic\LaravelTextToolkit\TextToolkitServiceProvider;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
abstract class TestCase extends OrchestraTestCase
{
protected function getPackageProviders($app): array
{
return [
TextToolkitServiceProvider::class,
];
}
protected function getPackageAliases($app): array
{
return [
'TextToolkit' => \Jakovic\LaravelTextToolkit\Facades\TextToolkit::class,
];
}
protected function defineEnvironment($app): void
{
$app['config']->set('text-toolkit.default_max_length', 100);
$app['config']->set('text-toolkit.ellipsis', '...');
$app['config']->set('text-toolkit.slug_separator', '-');
}
}
Let's break down what each method does:
getPackageProviders()tells Testbench which service providers to register. This is equivalent to your package being installed in a real Laravel app and auto-discovered.getPackageAliases()registers facade aliases - same as what would normally go inconfig/app.php.defineEnvironment()sets up config values. Since there's no published config file, you need to set the values your package expects.
Now update tests/Pest.php to use your base TestCase:
<?php
use Jakovic\LaravelTextToolkit\Tests\TestCase;
uses(TestCase::class)->in('Feature');
uses(TestCase::class)->in('Unit');
With this setup, every test file in your Feature and Unit directories will automatically boot a Laravel app with your package loaded. You get access to the container, facades, helpers - everything.
Create the test directories:
mkdir -p tests/Unit tests/Feature
Testing the Service Provider
Your service provider is the entry point for everything your package does. If it's broken, nothing works. Let's verify it registers things correctly.
Create tests/Feature/ServiceProviderTest.php:
<?php
use Jakovic\LaravelTextToolkit\TextToolkit;
it('registers the text toolkit in the container', function () {
$toolkit = app('text-toolkit');
expect($toolkit)->toBeInstanceOf(TextToolkit::class);
});
it('resolves the text toolkit as a singleton', function () {
$instance1 = app('text-toolkit');
$instance2 = app('text-toolkit');
expect($instance1)->toBe($instance2);
});
it('merges package config', function () {
expect(config('text-toolkit.default_max_length'))->toBe(100)
->and(config('text-toolkit.ellipsis'))->toBe('...')
->and(config('text-toolkit.slug_separator'))->toBe('-');
});
it('registers the blade component', function () {
$aliases = app('blade.compiler')->getClassComponentAliases();
expect($aliases)->toHaveKey('text-toolkit::truncate');
});
it('registers the blade directive', function () {
$directives = app('blade.compiler')->getCustomDirectives();
expect($directives)->toHaveKey('slugify');
});
These tests verify the fundamentals: your container bindings work, singletons are actually singletons, config merges correctly, and Blade components/directives are registered. If any of these fail, you'll catch it before your users do.
Run them:
./vendor/bin/pest tests/Feature/ServiceProviderTest.php
Testing the Facade
Facades are a thin layer, but they're the primary API most developers will use. Test them to make sure the proxy works correctly.
Create tests/Feature/FacadeTest.php:
<?php
use Jakovic\LaravelTextToolkit\Facades\TextToolkit;
it('can slugify text through the facade', function () {
$result = TextToolkit::slugify('Hello World');
expect($result)->toBe('hello-world');
});
it('can truncate text through the facade', function () {
$text = 'This is a long piece of text that should be truncated';
$result = TextToolkit::truncate($text, 20);
expect($result)->toBe('This is a long piece...');
});
it('can create an excerpt through the facade', function () {
$text = 'The quick brown fox jumps over the lazy dog. Another sentence here.';
$result = TextToolkit::excerpt($text, 1);
expect($result)->toBe('The quick brown fox jumps over the lazy dog.');
});
it('can convert markdown to plain text through the facade', function () {
$markdown = '# Hello **World**';
$result = TextToolkit::markdownToText($markdown);
expect($result)->toBe('Hello World');
});
it('uses config values for truncation defaults', function () {
config()->set('text-toolkit.default_max_length', 10);
config()->set('text-toolkit.ellipsis', '---');
$result = TextToolkit::truncate('This is a longer string');
expect($result)->toBe('This is a ---');
});
That last test is important - it checks that the facade properly reads from Laravel's config. This is something you can only verify with Testbench, since there's no config repository in plain PHP tests.
Testing Blade Components and Directives
In Part 9, we built a <x-text-toolkit::truncate> Blade component and a @slugify directive. Testing Blade rendering is one of the biggest reasons to use Testbench - you need the full view engine.
Create tests/Feature/BladeTest.php:
<?php
use Illuminate\Support\Facades\Blade;
it('renders the truncate blade component', function () {
$html = Blade::render(
'<x-text-toolkit::truncate :text="$text" :length="20" />',
['text' => 'This is a really long piece of text that needs truncating']
);
expect(trim($html))->toContain('This is a really lon...');
});
it('renders the truncate component with default length from config', function () {
config()->set('text-toolkit.default_max_length', 15);
$html = Blade::render(
'<x-text-toolkit::truncate :text="$text" />',
['text' => 'This is a really long piece of text']
);
expect(trim($html))->toContain('This is a reall...');
});
it('compiles the slugify blade directive', function () {
$compiled = Blade::compileString("@slugify('Hello World')");
expect($compiled)->toContain("app('text-toolkit')->slugify('Hello World')");
});
it('renders the slugify blade directive', function () {
$html = Blade::render("@slugify('Hello World')");
expect(trim($html))->toBe('hello-world');
});
The first two tests use Blade::render() to compile and render a component, then check the HTML output. This is the real deal - it goes through the exact same rendering pipeline as a live Laravel app.
The third test uses Blade::compileString() to check that the directive compiles to the correct PHP code. This is useful for debugging if your directive produces unexpected output. The fourth test goes a step further and actually renders it.
Testing Artisan Commands
In Part 9, we built a text-toolkit:slugify Artisan command. Laravel's test framework (and Testbench) gives you an excellent API for testing commands with artisan().
Create tests/Feature/CommandTest.php:
<?php
it('slugifies text through the artisan command', function () {
$this->artisan('text-toolkit:slugify', ['text' => 'Hello World'])
->expectsOutput('hello-world')
->assertExitCode(0);
});
it('truncates text through the artisan command', function () {
$this->artisan('text-toolkit:truncate', [
'text' => 'This is a very long text that should be truncated',
'--length' => 20,
])
->expectsOutput('This is a very long ...')
->assertExitCode(0);
});
it('shows an error when no text is provided', function () {
$this->artisan('text-toolkit:slugify')
->assertExitCode(1);
});
it('lists the text-toolkit commands', function () {
$this->artisan('list')
->assertExitCode(0);
// Verify commands are registered
$commands = \Illuminate\Support\Facades\Artisan::all();
expect($commands)->toHaveKey('text-toolkit:slugify')
->toHaveKey('text-toolkit:truncate');
});
The artisan() method boots the console kernel, runs your command, and lets you chain assertions about the output and exit code. You can also test interactive commands that ask questions:
<?php
it('asks for text interactively if not provided as argument', function () {
$this->artisan('text-toolkit:slugify')
->expectsQuestion('What text would you like to slugify?', 'Hello World')
->expectsOutput('hello-world')
->assertExitCode(0);
});
This simulates a user typing "Hello World" when prompted. Very handy for commands that have an interactive flow.
Testing Routes and Middleware
If your package registers routes (like we did in Part 9 with an API endpoint), you can test them with Laravel's HTTP testing methods. Testbench gives you the full HTTP kernel.
First, make sure your TestCase defines the routes. If your package loads routes in its service provider, they'll already be available. But you might need to set up the web or API middleware groups.
Create tests/Feature/RouteTest.php:
<?php
it('can slugify text via the api endpoint', function () {
$response = $this->postJson('/api/text-toolkit/slugify', [
'text' => 'Hello World',
]);
$response->assertOk()
->assertJson([
'result' => 'hello-world',
]);
});
it('can truncate text via the api endpoint', function () {
$response = $this->postJson('/api/text-toolkit/truncate', [
'text' => 'This is a very long text that should be truncated',
'length' => 20,
]);
$response->assertOk()
->assertJson([
'result' => 'This is a very long ...',
]);
});
it('returns validation error when text is missing', function () {
$response = $this->postJson('/api/text-toolkit/slugify', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['text']);
});
it('returns 404 when routes are disabled in config', function () {
config()->set('text-toolkit.routes_enabled', false);
// Re-register routes based on new config
$response = $this->postJson('/api/text-toolkit/slugify', [
'text' => 'Hello',
]);
$response->assertNotFound();
});
Now let's test middleware. Suppose your package has rate limiting middleware for the API routes:
<?php
it('applies rate limiting to api endpoints', function () {
config()->set('text-toolkit.rate_limit', 3);
// Make requests up to the limit
for ($i = 0; $i < 3; $i++) {
$this->postJson('/api/text-toolkit/slugify', ['text' => 'test'])
->assertOk();
}
// Next request should be rate limited
$this->postJson('/api/text-toolkit/slugify', ['text' => 'test'])
->assertStatus(429);
});
For testing custom middleware in isolation, you can define a test route in your TestCase:
<?php
namespace Jakovic\LaravelTextToolkit\Tests;
use Jakovic\LaravelTextToolkit\TextToolkitServiceProvider;
use Jakovic\LaravelTextToolkit\Http\Middleware\EnsureTextHeader;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
abstract class TestCase extends OrchestraTestCase
{
protected function getPackageProviders($app): array
{
return [
TextToolkitServiceProvider::class,
];
}
protected function defineRoutes($router): void
{
// Register a test-only route with your middleware
$router->middleware(EnsureTextHeader::class)
->get('/test-middleware', function () {
return response()->json(['status' => 'ok']);
});
}
}
The defineRoutes() method is another Testbench hook. It lets you add routes specifically for testing without polluting your package's actual route file. Now you can test your middleware directly:
<?php
it('blocks requests without the required header', function () {
$this->getJson('/test-middleware')
->assertStatus(400);
});
it('allows requests with the required header', function () {
$this->getJson('/test-middleware', ['X-Text-Format' => 'plain'])
->assertOk()
->assertJson(['status' => 'ok']);
});
Testing Config Publishing
One thing that's easy to break and hard to catch manually is config publishing. If you rename your config file or change its path, php artisan vendor:publish silently fails. Let's write a test for it.
Create tests/Feature/ConfigTest.php:
<?php
it('publishes the config file', function () {
$this->artisan('vendor:publish', [
'--provider' => 'Jakovic\LaravelTextToolkit\TextToolkitServiceProvider',
'--tag' => 'text-toolkit-config',
])->assertExitCode(0);
$configPath = config_path('text-toolkit.php');
expect(file_exists($configPath))->toBeTrue();
// Clean up
unlink($configPath);
});
it('has all expected config keys', function () {
$config = config('text-toolkit');
expect($config)
->toHaveKey('default_max_length')
->toHaveKey('ellipsis')
->toHaveKey('slug_separator');
});
it('can override config values at runtime', function () {
config()->set('text-toolkit.slug_separator', '_');
$result = app('text-toolkit')->slugify('Hello World');
expect($result)->toBe('hello_world');
});
it('publishes views', function () {
$this->artisan('vendor:publish', [
'--provider' => 'Jakovic\LaravelTextToolkit\TextToolkitServiceProvider',
'--tag' => 'text-toolkit-views',
])->assertExitCode(0);
$viewPath = resource_path('views/vendor/text-toolkit');
expect(is_dir($viewPath))->toBeTrue();
// Clean up
array_map('unlink', glob("$viewPath/*"));
rmdir($viewPath);
});
These tests actually run the publish command and verify the files appear where they should. The cleanup at the end removes the files so they don't interfere with other tests. In Testbench, the app directory resets between tests, but it's good practice to clean up explicitly.
Unit Tests vs Feature Tests
So far everything we've written is a feature test - it needs the full Laravel app to run. But you should also have unit tests for the core logic that doesn't depend on Laravel.
Since jakovic/laravel-text-toolkit wraps the base jakovic/text-toolkit package, most of the pure logic lives there. But if you have any package-specific logic, test it as a unit test.
Create tests/Unit/HelpersTest.php:
<?php
use Jakovic\LaravelTextToolkit\Support\TextFormatter;
it('formats text to title case', function () {
$formatter = new TextFormatter();
expect($formatter->titleCase('hello world'))->toBe('Hello World');
});
it('strips html tags and returns plain text', function () {
$formatter = new TextFormatter();
$html = '<p>Hello <strong>World</strong></p>';
expect($formatter->stripHtml($html))->toBe('Hello World');
});
it('counts words in a string', function () {
$formatter = new TextFormatter();
expect($formatter->wordCount('The quick brown fox'))->toBe(4);
});
These tests don't need Laravel at all. They're fast, isolated, and easy to debug. Keep your test suite balanced - unit tests for logic, feature tests for integration.
Testing With Different Configurations
A common source of bugs is your package not handling different config combinations well. Testbench makes it easy to test with different configurations using the defineEnvironment() method or by changing config inline.
Create tests/Feature/ConfigurationVariationsTest.php:
<?php
use Jakovic\LaravelTextToolkit\Facades\TextToolkit;
it('handles custom ellipsis characters', function () {
config()->set('text-toolkit.ellipsis', ' [...]');
$result = TextToolkit::truncate('This is a very long string', 10);
expect($result)->toBe('This is a [...]');
});
it('handles empty ellipsis', function () {
config()->set('text-toolkit.ellipsis', '');
$result = TextToolkit::truncate('This is a very long string', 10);
expect($result)->toBe('This is a ');
});
it('handles underscore slug separator', function () {
config()->set('text-toolkit.slug_separator', '_');
$result = TextToolkit::slugify('Hello Beautiful World');
expect($result)->toBe('hello_beautiful_world');
});
it('uses default config when values are missing', function () {
config()->set('text-toolkit.ellipsis', null);
$result = TextToolkit::truncate('This is a long string', 10);
// Should fall back to a sensible default
expect(strlen($result))->toBeGreaterThan(0);
});
You can also test different environments by creating separate test classes with their own defineEnvironment() method, or by using Pest's beforeEach():
<?php
use Jakovic\LaravelTextToolkit\Facades\TextToolkit;
describe('with disabled features', function () {
beforeEach(function () {
config()->set('text-toolkit.routes_enabled', false);
config()->set('text-toolkit.commands_enabled', false);
});
it('does not register api routes when disabled', function () {
$this->postJson('/api/text-toolkit/slugify', ['text' => 'test'])
->assertNotFound();
});
it('still works through the facade when routes are disabled', function () {
expect(TextToolkit::slugify('Hello World'))->toBe('hello-world');
});
});
Setting Up CI for Laravel Packages
The final piece is making sure your package works across multiple Laravel and PHP versions. A CI matrix is essential for Laravel packages because your users might be on different versions than you.
Create .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
laravel: [11.*, 12.*]
testbench: [9.*, 10.*]
exclude:
- laravel: 11.*
testbench: 10.*
- laravel: 12.*
testbench: 9.*
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip
coverage: none
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
composer update --prefer-dist --no-interaction
- name: Run tests
run: vendor/bin/pest
This workflow tests your package against every combination of PHP and Laravel versions you support. Let's break down the key parts:
- The matrix defines all combinations: PHP 8.2/8.3/8.4 with Laravel 11 and 12.
- The exclude rules prevent impossible combinations - Testbench 9 only works with Laravel 11, and Testbench 10 only works with Laravel 12.
- fail-fast: true stops all jobs if one fails, saving CI minutes.
- The install step first requires the specific Laravel and Testbench versions, then updates everything to resolve dependencies.
If you also want to run code style checks and static analysis in CI, add them as separate jobs:
code-style:
runs-on: ubuntu-latest
name: Code Style
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
coverage: none
- name: Install dependencies
run: composer install --no-interaction
- name: Run Pint
run: vendor/bin/pint --test
static-analysis:
runs-on: ubuntu-latest
name: Static Analysis
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
coverage: none
- name: Install dependencies
run: composer install --no-interaction
- name: Run PHPStan
run: vendor/bin/phpstan analyse
Useful Testbench Hooks
Before we wrap up, here are some additional Testbench hooks that come in handy for more complex packages:
<?php
namespace Jakovic\LaravelTextToolkit\Tests;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
abstract class TestCase extends OrchestraTestCase
{
// Load your service providers
protected function getPackageProviders($app): array
{
return [
\Jakovic\LaravelTextToolkit\TextToolkitServiceProvider::class,
];
}
// Set config values before the app boots
protected function defineEnvironment($app): void
{
$app['config']->set('text-toolkit.default_max_length', 100);
}
// Define routes for testing
protected function defineRoutes($router): void
{
// Add test-specific routes here
}
// Run database migrations (if your package has them)
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}
// Define the database setup (seeders, factories, etc)
protected function afterApplicationCreated($callback): void
{
// Runs after the app is fully set up
}
// Override the app's base path for testing
protected function getBasePath()
{
return __DIR__ . '/../vendor/orchestra/testbench-core/laravel';
}
}
The defineDatabaseMigrations() method is especially useful if your package includes migrations. Testbench uses an in-memory SQLite database by default, so your tests stay fast.
The Final Test Directory Structure
Here's what your test directory should look like when everything is in place:
laravel-text-toolkit/
tests/
Feature/
ServiceProviderTest.php
FacadeTest.php
BladeTest.php
CommandTest.php
RouteTest.php
ConfigTest.php
ConfigurationVariationsTest.php
Unit/
HelpersTest.php
Pest.php
TestCase.php
phpunit.xml.dist
.github/
workflows/
tests.yml
Run the full suite:
./vendor/bin/pest
You should see something like:
PASS Tests\Feature\ServiceProviderTest
- it registers the text toolkit in the container
- it resolves the text toolkit as a singleton
- it merges package config
- it registers the blade component
- it registers the blade directive
PASS Tests\Feature\FacadeTest
- it can slugify text through the facade
- it can truncate text through the facade
- it can create an excerpt through the facade
- it can convert markdown to plain text through the facade
- it uses config values for truncation defaults
PASS Tests\Feature\BladeTest
- it renders the truncate blade component
- it renders the truncate component with default length from config
- it compiles the slugify blade directive
- it renders the slugify blade directive
... (more tests)
Tests: 25 passed
Duration: 1.42s
What's Next
Your Laravel package now has a serious test suite. You can test service providers, facades, Blade components, Artisan commands, routes, middleware, config publishing, and different configuration combinations - all without maintaining a separate Laravel application.
The CI matrix means you'll catch compatibility issues before they reach your users. When a new version of Laravel drops, just add it to the matrix and run the pipeline.
In Part 11, we'll shift gears and build a Symfony bundle from the same base package. You'll see how the Symfony ecosystem handles things like dependency injection, configuration, and service registration - and how it compares to the Laravel approach we've been using.