Search

Testing Filament v5 Plugins with Pest, Orchestra Testbench and PHPStan

Testing Filament v5 Plugins with Pest, Orchestra Testbench and PHPStan

Introduction

A plugin's tests aren't really for you. They're for the moment you widen your constraint to ^4.0|^5.0, merge a contributor's pull request, or bump the minimum Laravel version, and need to know in two minutes whether you just broke a few hundred apps you've never seen.

Also Read: Best Filament Themes and Starter Kits for 2026 (v5-Ready)

In this part we give Filament Announcements a real test suite:

  • A Testbench TestCase that boots Filament, a fixture panel and an authenticated user
  • Pest tests for the model scope, the resource table, the create form, validation, per-panel config, the render hook and the live preview
  • A provider-ordering bug that made every Livewire test fail, and the one line that fixes it
  • PHPStan (Larastan), Pint and the CI matrix the skeleton gives you

Seven passing Pest tests for the Filament Announcements plugin

How Package Tests Differ from App Tests

In a Laravel app, tests boot your application. A package has no application, so Orchestra Testbench creates a throwaway Laravel app for each test, registers the service providers you list, and gives you the usual $this->get(), actingAs() and database helpers.

Also Read: Laravel and PHP

For a Filament plugin, that throwaway app needs more than your provider. It needs Livewire, every Filament package you use, and a panel with your plugin registered on it, because resources, render hooks and FilamentAnnouncementsPlugin::get() all assume a panel exists.

Step 1: The TestCase

The skeleton's tests/TestCase.php is a good start. Here's our finished version:

namespace TheWebTier\FilamentAnnouncements\Tests;

use BladeUI\Heroicons\BladeHeroiconsServiceProvider;
use BladeUI\Icons\BladeIconsServiceProvider;
use Filament\Actions\ActionsServiceProvider;
use Filament\FilamentServiceProvider;
use Filament\Forms\FormsServiceProvider;
use Filament\Infolists\InfolistsServiceProvider;
use Filament\Notifications\NotificationsServiceProvider;
use Filament\Schemas\SchemasServiceProvider;
use Filament\Support\SupportServiceProvider;
use Filament\Tables\TablesServiceProvider;
use Filament\Widgets\WidgetsServiceProvider;
use Livewire\LivewireServiceProvider;
use Orchestra\Testbench\TestCase as Orchestra;
use RyanChandler\BladeCaptureDirective\BladeCaptureDirectiveServiceProvider;
use TheWebTier\FilamentAnnouncements\FilamentAnnouncementsServiceProvider;
use TheWebTier\FilamentAnnouncements\Tests\Fixtures\AdminPanelProvider;
use TheWebTier\FilamentAnnouncements\Tests\Fixtures\User;

class TestCase extends Orchestra
{
    protected function getPackageProviders($app)
    {
        $providers = [
            ActionsServiceProvider::class,
            BladeCaptureDirectiveServiceProvider::class,
            BladeHeroiconsServiceProvider::class,
            BladeIconsServiceProvider::class,
            FilamentServiceProvider::class,
            FormsServiceProvider::class,
            InfolistsServiceProvider::class,
            LivewireServiceProvider::class,
            NotificationsServiceProvider::class,
            SchemasServiceProvider::class,
            SupportServiceProvider::class,
            TablesServiceProvider::class,
            WidgetsServiceProvider::class,
            FilamentAnnouncementsServiceProvider::class,
            AdminPanelProvider::class,
        ];

        // Keep this sort from the skeleton: registration order matters, and
        // Livewire must register after Filament's support provider.
        sort($providers);

        return $providers;
    }

    public function getEnvironmentSetUp($app): void
    {
        $app['config']->set('app.key', 'base64:' . base64_encode(str_repeat('a', 32)));
        $app['config']->set('database.default', 'testing');
        $app['config']->set('auth.providers.users.model', User::class);
    }

    /**
     * Every test boots a fresh app with an in-memory SQLite database,
     * so we simply run the migrations we need before each test.
     */
    protected function defineDatabaseMigrations(): void
    {
        $this->loadLaravelMigrations();

        $migration = include __DIR__ . '/../database/migrations/create_announcements_table.php.stub';
        $migration->up();
    }
}

A few things differ from the skeleton:

  • An app key. Any test that makes a real HTTP request through the session middleware needs one, or it fails with MissingAppKeyException.
  • Our own User model, set as the auth provider's model.
  • Our migration is included directly from the .stub file. It's the same file users publish, so the tests exercise exactly what ships. loadLaravelMigrations() adds Laravel's default users table.
  • We dropped the skeleton's LazilyRefreshDatabase trait. With it, the tables we created in defineDatabaseMigrations() were gone by the time the first query ran ("no such table: users"), because the trait's refresh wipes the database and only re-runs the migrations Laravel knows about. We didn't need it anyway: Testbench's testing connection is an in-memory SQLite database that starts empty for every test.

The provider-ordering bug

While tidying getPackageProviders(), we deleted the sort($providers) line the skeleton includes. It looked cosmetic. Immediately, every Livewire test failed with:

Illuminate\Support\ViewErrorBag::put(): Argument #2 ($bag) must be of type
Illuminate\Contracts\Support\MessageBag, null given

The cause is registration order. Filament's SupportServiceProvider binds its own DataStoreOverride class in place of Livewire's DataStore. When Livewire's provider registers after it, Livewire resolves that override once and stores it as a single shared instance, and everything works. When Livewire registers before it, Filament's bind() replaces Livewire's shared instance with a non-shared binding. From then on every call gets a brand-new, empty data store, so a component's error bag disappears between two lines of code.

Also Read: Auto-Post to LinkedIn from Laravel (2026 Guide)

sort() puts Filament\... ahead of Livewire\... alphabetically, which happens to match the order Laravel's package discovery uses in a real app. Keep the sort. If you ever add a provider whose name sorts badly, order the list by hand instead.

Step 2: Fixtures: a Panel and a User

Filament's authentication middleware only lets users into a panel if the user model implements FilamentUser and canAccessPanel() returns true. The one exception is the local environment, and tests run in testing. So the fixture user implements it:

namespace TheWebTier\FilamentAnnouncements\Tests\Fixtures;

use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements FilamentUser
{
    protected $guarded = [];

    protected $table = 'users';

    public function canAccessPanel(Panel $panel): bool
    {
        return true;
    }
}

The fixture panel registers the plugin the way a user would, including one option, so we can test that per-panel configuration reaches the resource:

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('admin')
            ->login()
            ->plugin(
                FilamentAnnouncementsPlugin::make()
                    ->navigationGroup('Settings'),
            )
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ]);
    }
}

Finally, tests/Pest.php signs a user in before every test:

uses(TestCase::class)
    ->beforeEach(function () {
        $this->actingAs(User::query()->create([
            'name' => 'Test User',
            'email' => '[email protected]',
            'password' => bcrypt('password'),
        ]));
    })
    ->in(__DIR__);

Step 3: Test the Model

Start with plain PHP logic. The scope that decides which banners are visible is the core of the plugin, and the factory's states make the test read like a spec:

it('only returns active announcements inside their schedule window', function () {
    $live = Announcement::factory()->create();
    $inactive = Announcement::factory()->inactive()->create();
    $scheduled = Announcement::factory()->scheduled()->create();
    $expired = Announcement::factory()->expired()->create();

    $visible = Announcement::query()->visible()->pluck('id');

    expect($visible)
        ->toContain($live->id)
        ->not->toContain($inactive->id)
        ->not->toContain($scheduled->id)
        ->not->toContain($expired->id);
});

Step 4: Test the Resource with Livewire

Every Filament page is a Livewire component, so you test resources by mounting their pages. The skeleton includes pestphp/pest-plugin-livewire, which provides the livewire() helper, and Filament adds testing helpers like fillForm(), assertHasFormErrors() and assertCanSeeTableRecords(). See Filament's testing resources guide for the full list.

use TheWebTier\FilamentAnnouncements\Enums\AnnouncementColor;
use TheWebTier\FilamentAnnouncements\Models\Announcement;
use TheWebTier\FilamentAnnouncements\Resources\Announcements\AnnouncementResource;
use TheWebTier\FilamentAnnouncements\Resources\Announcements\Pages\CreateAnnouncement;
use TheWebTier\FilamentAnnouncements\Resources\Announcements\Pages\ListAnnouncements;

use function Pest\Livewire\livewire;

it('lists announcements in the table', function () {
    $announcements = Announcement::factory()->count(3)->create();

    livewire(ListAnnouncements::class)
        ->assertOk()
        ->assertCanSeeTableRecords($announcements);
});

it('creates an announcement from the form', function () {
    livewire(CreateAnnouncement::class)
        ->fillForm([
            'title' => 'Scheduled maintenance',
            'body' => 'The panel will be read-only on Saturday.',
            'color' => AnnouncementColor::Warning,
        ])
        ->call('create')
        ->assertHasNoFormErrors();

    expect(Announcement::query()->where('title', 'Scheduled maintenance')->first())
        ->not->toBeNull()
        ->color->toBe(AnnouncementColor::Warning);
});

it('requires the end date to be after the start date', function () {
    livewire(CreateAnnouncement::class)
        ->fillForm([
            'title' => 'Broken schedule',
            'color' => AnnouncementColor::Info,
            'starts_at' => now()->addDay(),
            'ends_at' => now(),
        ])
        ->call('create')
        ->assertHasFormErrors(['ends_at' => 'after']);
});

Test the plugin's configuration

This is the test most plugin suites skip, and it's the one that catches regressions in register(). The fixture panel sets a navigation group, so the resource should report it:

it('uses the navigation group configured on the plugin', function () {
    expect(AnnouncementResource::getNavigationGroup())->toBe('Settings');
});

Test the render hook with a real request

Render hooks only run when a full page renders, so use an HTTP request rather than mounting a component. There's one subtlety: the list page shows every announcement in its table, including expired ones. Asserting that an expired title is missing would always fail there. The create page has no table, so any title on it must come from a banner:

it('renders live announcements as banners, but not expired ones', function () {
    Announcement::factory()->create(['title' => 'Live banner']);
    Announcement::factory()->expired()->create(['title' => 'Old banner']);

    // The create page has no table, so any title we see must come from the banner.
    $this->get(AnnouncementResource::getUrl('create'))
        ->assertOk()
        ->assertSee('Live banner')
        ->assertDontSee('Old banner');
});

Test the custom component

The live preview from Part 4 is reactive, so test that it actually reacts:

it('previews the announcement while it is being written', function () {
    livewire(CreateAnnouncement::class)
        ->assertSee('Your announcement title')
        ->fillForm(['title' => 'Preview me'])
        ->assertSee('Preview me')
        ->assertDontSee('Your announcement title');
});

Keep the skeleton's arch test

The skeleton ships tests/DebugTest.php, a Pest architecture test that fails if dd(), dump() or ray() sneak into your code:

it('will not use debugging functions')
    ->expect(['dd', 'dump', 'ray'])
    ->each->not->toBeUsed();

It costs nothing and saves you from shipping a dd() to every one of your users.

Also Read: Laravel: Create a LinkedIn

Step 5: Static Analysis and Code Style

The skeleton wires up three tools, each with a Composer script:

composer test      # Pest
composer analyse   # PHPStan with Larastan
composer lint      # Pint (fixes code style)
composer refactor  # Rector

PHPStan with Larastan runs at level 4, with checkModelProperties enabled. That's why our model has @property annotations: Larastan checks attribute access against them. On the finished plugin it reports no errors.

Pint caught import ordering in our model on the first run. Run composer lint before every commit, or let the skeleton's fix-code-style.yml workflow commit the fixes for you.

Also Read: Get a LinkedIn OAuth PHP Access Token in Laravel (2026)

Step 6: Continuous Integration

The skeleton's tests.yml runs your suite across a wide matrix:

  • OS: Ubuntu and Windows
  • PHP: 8.2, 8.3 and 8.4
  • Laravel: 11, 12 and 13 (with Testbench 9, 10 and 11), excluding PHP 8.2 on Laravel 13
  • Dependencies: prefer-lowest and prefer-stable

The prefer-lowest run matters more than it looks. It installs the oldest versions your constraints allow, so if you use a Filament method added in 5.6 while your composer.json says ^5.0, this is the job that catches it.

If you support both Filament v4 and v5, add a filament dimension to the matrix and require it before installing:

matrix:
  filament: [4.*, 5.*]
# ...
- name: Install dependencies
  run: |
    composer require "filament/filament:${{ matrix.filament }}" "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
    composer update --${{ matrix.stability }} --prefer-dist --no-interaction

Repository hygiene the directory checks

Filament's plugin directory gives every plugin a health score, and several of its checks are about exactly this kind of hygiene:

  • GitHub Actions pinned to a commit SHA (the skeleton does this)
  • Dependabot or Renovate configured, with an update cooldown (the skeleton does this)
  • Dependabot pull requests not left open
  • A security policy (.github/SECURITY.md, so replace the placeholder email)
  • composer.lock not committed by a library (add it to .gitignore, which the skeleton doesn't do)
  • A lean dist archive (the skeleton's .gitattributes)
  • Support for the current PHP, Laravel and Symfony versions

We cover the full list in Part 6.

Also Read: Renew LinkedIn Access Tokens in Laravel (60-Day Fix)

Key Takeaways

  • Testbench boots a fresh Laravel app per test. For Filament, register Livewire, the Filament packages, your provider and a fixture panel that uses your plugin.
  • Keep sort($providers). If Livewire registers before Filament's support provider, every Livewire test fails with a null error bag.
  • Fixture users must implement FilamentUser, because tests don't run in the local environment.
  • Test resources with livewire() and Filament's helpers, and test render hooks with real HTTP requests on a page that won't give false positives.
  • Test your plugin's configuration, not just its features.
  • Let CI run prefer-lowest across PHP 8.2 to 8.4 and Laravel 11 to 13. Add a Filament version dimension if you support v4 and v5.

In Part 6 we ship it: versioning, Packagist, becoming a Filament author, the plugin directory's submission and review process, and how the health score works.

FAQ

How do I test a Filament plugin without a Laravel app?

Use Orchestra Testbench. It boots a minimal Laravel application for each test. Register Livewire, the Filament service providers, your package's provider and a fixture PanelProvider in getPackageProviders().

Why do my Filament Livewire tests fail with "ViewErrorBag::put(): Argument #2 must be of type MessageBag, null given"?

Your service providers are registering in the wrong order. Filament's SupportServiceProvider must register before LivewireServiceProvider. The skeleton's sort($providers) does this for you, so don't remove it.

Why do I get a 403 when testing Filament pages?

Outside the local environment, Filament requires the user model to implement FilamentUser and return true from canAccessPanel(). Use a fixture user model that does.

Should a Filament plugin commit composer.lock?

No. Libraries shouldn't commit composer.lock, and "composer.lock not committed by library" is one of the Filament directory's health checks. Add it to .gitignore.

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy