Skip to main content
Jump24 - Official Laravel Partner
Development

Pest 5's Tia Engine: How 10 Minutes Becomes 4 Seconds.

12 minutes

So Test suites, we all know that we should be writing tests and that our applications should have a comprehensive test suite, but we all know the more tests we have the slower that suite becomes and overtime this has an impact on the way we develop.

The real cost of a slow suite isn't the ten minutes that it might take. It's what those ten minutes do to your habits. Nobody runs a ten-minute suite before every commit do they? They typically would run it before pushing the code up, thats if they remember of course or have if they have pre-commit hooks enabled. Then they stop running it locally all together and let CI find the breakage twenty minutes later, by which point they've moved on to something else and have to bring the whole problem back into their head.

We see this constantly on projects we take over. The suite exists, the coverage is respectable, and not one developer on the team runs it while they work. The feedback loop is so long it's stopped being a feedback loop and more being a coffee break.

So when Nuno Maduro walked on stage at Laracon US in Boston on Tuesday and announced Pest 5 with a claim that a ten-minute Laravel suite now replays in about four seconds, that got our attention for reasons that have nothing to do with saving ten minutes.

Fair warning before we go any further: Pest 5 was tagged and released during the conference. We slowly been bringing this into our client projects and our in-house projects to see what benefits we can see. This post is us reading the implementation carefully and working out what it means for the way we work - and there's a results section further down that we'll fill in with real numbers from our own suite.

What Tia actually is

Tia is short for Test Impact Analysis. The concept isn't new - it's been in the .NET and JVM worlds for years - but this is the first properly integrated implementation in the PHP ecosystem that I have seen in the wild.

The idea: your test suite already knows which parts of your application each test touches. It just throws that information away after every run. Well Tia keeps it.

The first time you run with Pest with the --tia flag, Pest records a dependency graph of which tests depend on which files. Every run after that, it compares your working tree against that graph, re-runs only the tests affected by what you actually changed, and replays cached results for everything else.

shell
1./vendor/bin/pest --parallel --tia

The output tells you exactly what happened:

shell
1Tests: 774 passed (2658 assertions, 7 affected, 2 uncached, 765 replayed)
2 
3Duration: 3.92s

There are three numbers worth understanding. affected is the set of tests Pest re-ran because their dependencies changed. uncached is tests it had to execute because no cached result existed yet. replayed is the set served from cache.

But is this trustworthy?

Here's where most people's scepticism lands, and it's the right scepticism: a test that didn't run didn't pass. So how is a replay not just a very confident skip?

Because Pest doesn't cache the pass/fail result. It caches everything the test produced, down to the exact lines and branches it covered. A replayed run reports the same coverage as a full run. --coverage reports and --min thresholds behave as though every single test executed from scratch.

That distinction matters enormously for us. We run PHPStan and our test suites through GitHub Actions on every pull request, and several of our client projects have coverage thresholds that fail the build. If Tia had quietly degraded coverage reporting, it would have been unusable in CI regardless of how fast it was. It doesn't, so it isn't.

The dependency graph is cleverer than we expected

This is the part that really made us smile. We assumed Tia would track PHP files through the coverage driver and give up on everything else. It doesn't.

  • PHP source files are tracked through the coverage driver, as you'd expect. Change app/Models/User.php and only tests that touched User re-run.

  • Migrations get intersected with the tables each test queried during the baseline run. Rename a column in create_users_table.php and only the tests that actually queried the users table re-run. That's a very clever piece of engineering.

  • Blade templates re-run only the tests that rendered them - including renders triggered by browser tests.

  • Inertia pages under resources/js/Pages re-run only the tests that server-side rendered them.

  • Shared JS components are resolved by walking Vite's module graph to find which pages import them. Edit a button component and Pest works out which Inertia pages pull it in, then which tests rendered those pages.

  • Arch tests re-run whenever project PHP source changes, because architecture expectations inspect files by namespace and path rather than executing them.

Given how much Inertia work we do, that Vite module graph traversal is the detail that convinced us this isn't a toy. Pest detects Laravel, Symfony, Livewire, Inertia and browser assets through Composer, so none of this needs configuring.

Cosmetic changes run nothing at all

Pest normalises file content before hashing it. PHP files have whitespace, line comments and docblocks stripped. Blade strips {{-- … --}} comments. JS, TS, Vue and Svelte lose their line and block comments.

The practical consequence: a Pint pass, a Prettier reformat, a comment-only edit or a README tweak produces an identical hash. The file never enters the changed set. Zero tests run.

If you've ever sat watching a full suite grind through because you reformatted a file, you'll appreciate that more than the headline number.

Sharing the baseline, this is where it gets interesting

Recording the baseline locally takes minutes on a large suite, and it needs a coverage driver. For a single product team that's a one-off annoyance. For us, with a lot of client repos and developers moving between them, paying that cost per project per machine would be genuinely irritating.

No need to worry Nuno has already thought about this. You can have CI record it once and have everyone download it. It's opt-in, and for a team you'd set it in tests/Pest.php:

php
1pest()->tia()->baselined();

Then setup a workflow similar to this that records and uploads the baseline on every merge to main:

yaml-frontmatter
1name: TIA Baseline
2on:
3 push: { branches: [main] }
4 schedule: [{ cron: '0 3 * * *' }]
5 workflow_dispatch:
6jobs:
7 baseline:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11 with: { fetch-depth: 0 }
12 - uses: shivammathur/setup-php@v2
13 with: { php-version: '8.4', coverage: xdebug }
14 - run: composer install --no-interaction --prefer-dist
15
16 - name: Run tests
17 run: ./vendor/bin/pest --parallel --tia --coverage --fresh
18
19 - name: Resolve TIA baseline path
20 id: baseline
21 run: echo "path=$(./vendor/bin/pest --baseline)" >> "$GITHUB_OUTPUT"
22
23 - name: Upload TIA baseline
24 uses: actions/upload-artifact@v4
25 with:
26 name: pest-tia-baseline
27 path: ${{ steps.baseline.outputs.path }}
28 include-hidden-files: true
29 retention-days: 30

Note include-hidden-files: true - the baseline lives under a dot-prefixed directory (there is a PR to allow this to be changed with a new method) and the upload silently gets you nothing without it. And note the workflow filename matters: Pest looks for the latest successful run of a tia-baseline.yml workflow specifically.

After that, any developer with baselined() enabled downloads the baseline on their first --tia run and starts replaying immediately. Nobody pays the record cost but CI.

Configuration you'll probably want

php
1pest()->tia()
2 ->always() // no --tia flag needed
3 ->locally() // restrict always() to local machines
4 ->baselined() // fetch the shared baseline from CI
5 ->filtered(); // narrow PHPUnit to affected test files only

Our instinct is always()->locally()->baselined(). Tia on by default while developers work, off in CI where you want the genuine full run, and baselines pulled from CI so nobody records locally. An explicit --tia still works regardless, and --no-tia disables it for a single run.

--tia --fresh discards the graph and re-records, which is what you'll want after a large refactor. --filtered narrows PHPUnit to only the affected files rather than loading the full suite - worth knowing that it's automatically disabled when you pass an explicit test path or a --coverage report, and that if nothing is affected Pest just stops and tells you.

Our results

We've set this up on an internal CRM that we've built this CRM has been developed with Laravel 13 and Filament so the numbers you see here are for a none client project , but one we still take pride in developing and making sure we have the test coverage we require.

Project

Tests

Full suite

Baseline record

Typical replay

Internal CRM - Laravel 13, FilamentPHP

4016

79.34s

96.73s

1-2s

When we make a change to a file for instance the UserModel which has 265 affected tests attached to it the speed or a replay then is

The Gotcha's

PHP 8.4 and PHPUnit 13

Pest 5 requires PHP 8.4 or greater and runs on PHPUnit 13. For a lot of teams that's the whole conversation over before it starts. Realistically, a meaningful chunk of the client projects we maintain aren't on PHP 8.4 yet, and the PHPUnit 13 jump is where most of the upgrade friction lives rather than Pest itself. Read the PHPUnit 13 changelog before you commit to an afternoon.

If you're on Pest 4 and PHP 8.3, this feature is not available to you today. That's not a criticism - it's just the position a lot of people might be in.

You need a coverage driver

Tia needs PCOV or Xdebug installed and enabled to record the baseline. Without one, it won't run at all. If your local setup deliberately runs without a coverage driver for speed, you'll need to change that, at least for the record run, setting one of these up is a trivial task and having them work alongside tools like Laravel Herd is easy to do.

Some changes still re-run everything

Anything Pest can't statically attribute to specific tests falls through to a broad pattern. Editing config/app.php re-runs your entire suite, because there's no way to prove which tests depend on it. Same for route files and fixture data.

In our experience of real Laravel work, config and route files get touched more often than people assume. So the honest expectation is not "every run is four seconds" - it's "most runs are fast, and some days you get a full run anyway."

Structural changes rebuild the graph entirely: composer.lock, phpunit.xml, vite.config.*, Node lockfiles and tsconfig/jsconfig. A composer update means a fresh baseline. Bumping your PHP version invalidates cached results while keeping the graph.

Baseline sharing is GitHub-only

It leans on the GitHub CLI, so it only works for repositories hosted on GitHub, and gh must be installed and authenticated on the machine doing the fetch. GitLab and Bitbucket shops record locally. If a fetch fails there's a 24-hour cooldown before it tries again, which --tia --refetch bypasses.

Tia Cache lives outside your project

State is stored at ~/.pest/tia/<project-key>/, where the key comes from your normalised git remote URL. Multiple worktrees of the same repository share one cache, which is sensible. But it does mean the cache isn't in your project, isn't in version control, and won't come along in a fresh container unless you fetch the baseline. As mentioned before though there is a PR that could change this giving you the ability to change the location of the cache.

The numbers depend heavily on your suite

The figures being quoted vary quite a bit - the docs describe a ten-minute suite replaying in around four seconds, the Pest homepage talks about a fifteen-second suite returning in under a second, and Taylor Otwell reported Laravel Cloud's 19,000-test suite going from three minutes to five seconds. All plausible, all measuring different suites on different hardware.

What determines your outcome is how well-isolated your tests are. A suite where every test hits a broad service layer will see far more tests marked as affected than one with tight unit boundaries. Slow suites that are slow because of heavy per-test database setup will still be slow for the tests that do run. Tia reduces how many tests run - it doesn't make an individual test faster.

So should you use it?

If you're already on PHP 8.4 and Pest 4, yes, and the upgrade is genuinely close to a one-line change in composer.json. Turn it on locally, leave CI running the full suite, and see what happens to how often your team actually runs tests while they work.

If you're not on PHP 8.4, this is one more entry on the pile of reasons to get there. And it's a decent argument to bring to a client who's been resisting a version bump - "our developers will catch bugs before CI does" lands better than "we'd like to be on a newer PHP."

What we like most about this release isn't the speed. It's that it goes after a behavioural problem rather than a technical one. A suite fast enough to run on every save is a suite people run. That's a much bigger win than the seconds saved.

Enormous credit to Nuno Maduro for this one - and worth remembering he also maintains Larastan, which has been quietly propping up our static analysis setup for years.

Over to you: how long does your suite take, and be honest - do you actually run it locally before pushing? We'd love to hear from teams who've already got Tia on a large suite, particularly if the graph struggled anywhere.

Looking to get a legacy Laravel application onto a modern stack?

Slow test suites and outdated PHP versions compound each other. Our team specialises in bringing legacy Laravel applications up to date without stopping delivery. Get in touch to talk it through.