fruitcake / phpunit-tia-laravel
Laravel integration for phpunit-tia: links tests to the views they render and the migrations of the tables they query
Requires
- php: ^8.4
- illuminate/contracts: ^13.0
- illuminate/support: ^13.0
- jasonmccreary/phpunit-tia: ^0.5.0
Requires (Dev)
- inertiajs/inertia-laravel: ^3.5
- laravel/pint: ^1.24
- livewire/livewire: ^4.4
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^13.2.6
- symfony/process: ^7.0|^8.0
Suggests
- inertiajs/inertia-laravel: Links Inertia page components
- livewire/livewire: Links Livewire 4 single-file and multi-file components (^4.0)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-20 08:35:30 UTC
README
Laravel support for phpunit-tia, the test impact analysis extension that skips the PHPUnit tests a change cannot affect.
Line coverage only sees PHP that runs inside <source>. It misses the views a Laravel test renders (Blade runs a compiled copy in storage/) and the migrations of the tables it queries. Without this package, phpunit-tia can skip a test that a view or migration change affects. Or it falls back to a directory guess that reruns far more than needed.
With this package, each test is linked to:
- every view it renders: pages, layouts, includes, Blade components, mail templates.
- the migrations of every table it queries.
- the Livewire 4 single-file and multi-file components it renders.
- the Inertia page component of every response it gets.
New files no test has used yet are resolved too. A new migration runs the tests that use the tables it changes. A new partial or component runs the tests of the views that use it.
phpunit-tia is an in-between step until PHPUnit ships test impact analysis of its own; see PHPUnit's own test impact analysis.
Requirements
- PHP 8.4 and Laravel 13
- PHPUnit 13.2.6 or newer
- pcov or Xdebug in
coveragemode, for the runs that record. Replaying a recorded baseline works without one.
Setup
Four steps cover a regular Laravel app, including Livewire and Inertia.
1. Install the package together with phpunit-tia:
composer require --dev fruitcake/phpunit-tia-laravel
2. Register the extension in phpunit.xml:
<extensions> <bootstrap class="JMac\Testing\PhpUnit\Tia\Extension"/> </extensions>
3. Add the trait to tests/TestCase.php:
namespace Tests; use Fruitcake\PhpUnitTia\Laravel\RunWithLaravelTia; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { use RunWithLaravelTia; }
4. Add a phpunit-tia.php next to phpunit.xml, so new files are resolved:
<?php return [ 'resolvers' => [ Fruitcake\PhpUnitTia\Laravel\LaravelResolver::class, ], ];
That's it. The first run records a baseline. After that, a run only executes the tests your changes can affect and marks the rest as skipped (S). Locally the baseline lives in ~/.phpunit-tia; to use TIA in CI, persist it between runs as described in phpunit-tia's CI docs.
Useful switches, all from phpunit-tia:
PHPUNIT_TIA=0 php artisan test # run everything, ignore TIA PHPUNIT_TIA_FRESH=1 php artisan test # rebuild the baseline PHPUNIT_TIA_DEBUG=1 php artisan test # explain why each test ran
What runs after a change
A changed file that a test is already linked to runs that test. For new files, the resolver answers only when it is sure. Otherwise it leaves the file to phpunit-tia's fallback, which runs the tests linked to other files in the same directory.
| Change | Runs |
|---|---|
| A view, component or migration a test used | That test |
New migration that alters invoices |
The tests that use invoices |
New migration with ->constrained('users') or ->on('users') |
Also the tests that use users |
| New migration that only creates a new table | Nothing; no test can use that table yet |
| New migration that alters a table with no earlier migration (such as a package's table) | Fallback |
New migration it cannot fully read: Schema::connection(), Schema::table($this->table), concatenated SQL, a model, app('db') |
Fallback |
| New partial, Blade component or Livewire component | The tests of the views, app/ classes and route files that use it, directly or through other views |
| New view that nothing uses yet | Fallback |
Any new view, while some view picks one at runtime (@include($name), <x-dynamic-component :component="$name">, @livewire($name)) |
Fallback |
| A class with no executable lines (an enum with only cases, an interface, a class with only constants) | The tests of the files that name it: through use, its full name (also as a string), or its short name in the same namespace |
| A new class that nothing names yet | Fallback |
| A class that only runs while the application boots, such as the route registration of a Filament resource | The tests that use it after booting |
| A service provider | Every test |
A config or route file, bootstrap/app.php, a factory, a seeder, a schema dump, a translation or .env.testing |
Every test |
Any other test code, such as the base TestCase, a trait or a fixture |
Every test |
Everything from setUp() to tearDown() counts, so a request, factory or seeder in setUp() links its views and tables too.
Booting the application is the exception. Every test boots it, and booting registers the routes of every Filament resource and page, so coverage would link each test to each of them, and a change to one resource would run the whole suite. The package leaves the boot out of the coverage and links the service providers it loaded instead.
Customizing
None of this is needed for a regular setup.
Your TestCase already has setUp()
The trait brings its own setUp(). Alias it and call it first:
abstract class TestCase extends BaseTestCase { use RunWithLaravelTia { RunWithLaravelTia::setUp as tiaSetUp; } protected function setUp(): void { $this->tiaSetUp(); // ... } }
A test that phpunit-tia skips never gets past that call, but tearDown() still runs. Guard any of your own teardown that needs the application with if (! $this->skippedByTia()).
Your TestCase already has refreshApplication()
The trait brings its own refreshApplication(), which boots the application without its coverage (see What runs after a change). Yours replaces it, so wrap your boot in withoutBootCoverage():
abstract class TestCase extends BaseTestCase { use RunWithLaravelTia; protected function refreshApplication(): void { $this->withoutBootCoverage(function (): void { $this->app = $this->createApplication(); // ... }); } }
Without it, every test is linked again to each class the boot runs, such as every Filament resource.
You already use RunWithTia
Swap it for RunWithLaravelTia, which includes it. Use RunWithLaravelTia rather than its parts, so that moving to PHPUnit's own test impact analysis does not touch your TestCase.
Migrations outside database/migrations
Recording follows every directory the migrator runs, loadMigrationsFrom() paths included. The resolver treats any file that extends Migration as a migration. It looks for the earlier migrations of its tables in database/migrations and in its own directory. When a module alters a table created elsewhere, pass those paths (globs work):
return [ 'resolvers' => [ new Fruitcake\PhpUnitTia\Laravel\LaravelResolver( migrationPaths: ['database/migrations', 'modules/*/database/migrations'], ), ], ];
Choosing what is recorded
Recording is done by four collectors in Fruitcake\PhpUnitTia\Laravel\Collectors: Views, Queries, Livewire and Inertia. Livewire and Inertia do nothing when the package isn't installed. To leave one out or add your own, override usedFileCollectors():
use Fruitcake\PhpUnitTia\Laravel\Recorder; abstract class TestCase extends BaseTestCase { use RunWithLaravelTia; private static ?array $collectors = null; protected function usedFileCollectors(): array { return self::$collectors ??= [...Recorder::defaultCollectors(), new FixtureFiles]; } }
A collector implements Collector. register() runs once per test with that test's application; add each file the test uses to $files, as an absolute or project-relative path:
use Fruitcake\PhpUnitTia\Laravel\Collectors\Collector; use Fruitcake\PhpUnitTia\Laravel\UsedFiles; use Illuminate\Contracts\Foundation\Application; final class FixtureFiles implements Collector { public function register(Application $app, UsedFiles $files): void { $app->make('events')->listen(FixtureLoaded::class, fn (FixtureLoaded $event) => $files->add($event->path)); } }
Return the same collector instances every time, as above, so a collector can keep what it reads between tests.
Files of your own that every test depends on
Coverage doesn't see files outside <source>, so no test is linked to them. ApplicationResolver runs every test for the ones every Laravel app has (ApplicationResolver::PATHS), and for test code that is not a test itself: any other file in the test suite directories of your phpunit.xml or the autoload-dev paths of your composer.json, such as tests/TestCase.php beside tests/Unit and tests/Feature. Add paths of your own, such as a script that tests run:
use Fruitcake\PhpUnitTia\Laravel\LaravelResolver; use Fruitcake\PhpUnitTia\Laravel\Resolvers\ApplicationResolver; return [ 'resolvers' => [ new LaravelResolver(applicationPaths: [...ApplicationResolver::PATHS, 'scripts/*']), ], ];
Choosing how new files are resolved
LaravelResolver combines ApplicationResolver, MigrationResolver, ViewResolver and ClassResolver from Fruitcake\PhpUnitTia\Laravel\Resolvers, in that order. Register them separately to leave one out or to put a resolver of your own between them. Each implements phpunit-tia's EdgeAwareResolver:
use Fruitcake\PhpUnitTia\Laravel\Resolvers\ApplicationResolver; use Fruitcake\PhpUnitTia\Laravel\Resolvers\ClassResolver; use Fruitcake\PhpUnitTia\Laravel\Resolvers\MigrationResolver; use Fruitcake\PhpUnitTia\Laravel\Resolvers\ViewResolver; return [ 'resolvers' => [ ApplicationResolver::class, new MigrationResolver(['database/migrations', 'modules/*/database/migrations']), App\Testing\TranslationResolver::class, ViewResolver::class, ClassResolver::class, ], ];
A resolver returns the test files a changed path affects, an empty list when it affects none, or null to leave the path to the next resolver.
PHPUnit's own test impact analysis
phpunit-tia is an in-between step. sebastianbergmann/phpunit#6919 adds test impact analysis to PHPUnit itself, and this package will move to it once that is released. What changes for an app:
RunWithLaravelTiaand your own collectors stay. The collectors hand their files to PHPUnit'sregisterFixture()instead of to phpunit-tia, and phpunit-tia's skipping (S,skippedByTia()) goes: PHPUnit does not run the tests a change cannot affect.phpunit-tia.phpand the resolvers go. PHPUnit has no place for an extension that decides what a new file affects. Files every test depends on are watched inphpunit.xml, and a new migration is matched to the tests of its tables through a file per table that the tests register.- phpunit-tia's extension in
phpunit.xmlis replaced by PHPUnit's own options, such as--record-test-impact-dataand--only-impacted.
Caveats
RefreshDatabasemigrates and seeds once per process, in the first test. Only that test is linked to the tables the seeders touch. UseDatabaseMigrationsor seed insetUp()to link every test.- Inertia pages are linked, not the JavaScript components they import. A PHP test sees which page renders, not what it looks like.
- New views are found in
resources/views, new Livewire components in Livewire's default locations, and their users inresources/views,app/androutes/. Files elsewhere, such as package views, are still linked once a test renders them; only new ones fall back. - Table names are read with regular expressions, not a SQL parser. A missed table means fewer links, so keep a full run on your main branch as the baseline.
Testing
composer test
The end-to-end tests run the fixture app in tests/Fixtures/app with the real extension, so they need pcov or Xdebug. Without a coverage driver they are skipped.
License
MIT