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 in config/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.