DEV Community

Cover image for Using Test Impact Analysis on Slow Laravel Test Suites Without Losing Trust
Saqueib Ansari
Saqueib Ansari

Posted on Originally published at qcode.in

Using Test Impact Analysis on Slow Laravel Test Suites Without Losing Trust

If your Laravel suite is slow enough that developers hesitate before running it, you do not really have a fast feedback loop. You have a compliance ritual. That becomes a bigger problem once agents enter the workflow, because agents amplify whatever loop you give them. If verification takes twelve minutes, the agent either waits uselessly, skips checks, or pushes too much uncertainty downstream.

The practical answer for most PHP teams is test impact analysis locally, full-suite verification in CI, and explicit rules for when selective execution is too risky. That gives you a fast inner loop without lying to yourself about what counts as real confidence.

For Laravel teams, this is now a serious option. Pest 5 introduced built-in test impact analysis through --tia, and Laravel’s testing stack already gives you the parallel execution hooks you need to keep fallback runs sane. The hard part is not turning the feature on. The hard part is designing a workflow that stays trustworthy when the codebase, the team, and the suite get messy.

The Real Problem Is Not Just Runtime

A slow suite hurts in more than one way.

The obvious cost is wall-clock time. A developer changes one policy, one listener, or one validation rule, then waits minutes for a result. That is annoying, but it is still the shallow version of the problem.

The deeper cost is behavioral. Once the suite becomes expensive, people stop using it as a steering mechanism. They batch unrelated edits together. They verify less often. They defer broad checks until the end of the branch. Agents do the same thing even faster. Instead of validating each change in small increments, they accumulate risk and hope the next big run explains what broke.

That is why Laravel test impact analysis matters. It is not only about making tests faster. It is about restoring a development rhythm where verification can happen after small, frequent changes.

Pest’s TIA model is appealing for exactly this reason. The first run builds a dependency graph from coverage data, using a driver such as PCOV or Xdebug. Later runs look at what changed, rerun the tests that depend on those files, and replay cached results for the rest. Pest’s own release docs position this as a local development feature, not as a substitute for full CI verification, and that framing is correct.

If you ignore that framing, you create a false sense of safety. If you respect it, you get a much better developer loop.

What a healthy loop should feel like

A healthy Laravel testing workflow should make these things cheap:

  • rerunning a narrow slice after a small refactor
  • verifying a bug fix before moving to the next file
  • letting an agent validate each patch instead of dumping one giant speculative change
  • widening the scope when the blast radius starts to grow

That last point matters most. Good teams do not ask one testing mode to solve everything. They use different levels of verification for different kinds of uncertainty.

Where Test Impact Analysis Actually Wins

The biggest win is not the first-run benchmark. It is the reduction in friction after the first run.

Once the dependency graph exists, your team can stop treating the whole suite as the default response to every tiny edit. A small change to a controller, request object, or service class should not force the same verification cost as a migration rewrite or authentication refactor.

That sounds obvious, but many Laravel codebases still behave as if every touched file deserves a full ceremonial run. That habit survives because tooling used to make the alternative awkward. It is less defensible now.

Best-fit projects

Test impact analysis works especially well in Laravel projects with these properties:

  • a real separation between unit and feature tests
  • request flows that are tested by intent, not by giant catch-all files
  • factories and fixtures with predictable side effects
  • limited hidden state in shared helpers or global bootstrapping
  • developers who work in small batches instead of all-day mega-branches

In those codebases, TIA becomes a force multiplier. It keeps local checks cheap while preserving enough relevance that the result is useful.

Why this matters for agent-assisted development

Agents are not patient engineers. They are throughput machines. If you give them a ten-minute verification cycle, they will either use it badly or avoid it. If you give them a three-second impacted run plus a clear escalation rule for wider checks, they become much more useful.

That is the real opportunity here. TIA turns verification from an end-of-branch event into an every-few-minutes event. That is exactly the cadence agents need.

A realistic local command set

Do not overcomplicate the first rollout. Three commands are enough for most teams:

{
  "scripts": {
    "test:impact": "./vendor/bin/pest --parallel --tia",
    "test:quick": "php artisan test --parallel --stop-on-failure",
    "test:full": "php artisan test --parallel --recreate-databases"
  }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally boring.

test:impact is the default inner loop. test:quick is the broader local check before a push when the change touches meaningful app behavior. test:full is the expensive reset when the branch has become wide enough that you want a fresh, broad pass with recreated databases.

You can add more scripts later, but the bigger mistake is starting with a clever matrix of commands nobody remembers.

The Trust Boundary Has To Be Explicit

The most common failure with selective testing is not a bug in the tool. It is a bug in the team’s interpretation of green.

If you do not define this clearly, people start treating a fast impacted run as equivalent to a branch-wide verification result. It is not.

The safest rule is brutally simple:

  • impacted green means the recent change is probably safe to continue
  • broader local green means the branch is likely stable enough to push
  • full-suite CI green means the branch is ready to trust

Those three signals are related, but they are not interchangeable.

Why CI should stay conservative

Pest’s official guidance says TIA is for local development and that CI should run the full suite against a clean checkout. That is the right architectural line.

There are good reasons for that:

  • CI should not depend on a developer’s local cache state
  • protected-branch verification should be deterministic and boring
  • cross-cutting failures often emerge only in a clean environment
  • replayed results are valuable for iteration, but full-system trust belongs to a fresh run

Laravel’s parallel testing support is strong enough that full-suite CI does not have to be painfully slow if the suite is structured competently. Use php artisan test --parallel, tune process count, and shard later only if you actually need it.

A sane CI shape

A straightforward GitHub Actions job is enough to enforce the trust boundary:

name: test

on:
  pull_request:
  push:
    branches: [main]

jobs:
  suite:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: pcov
      - run: composer install --no-interaction --prefer-dist
      - run: php artisan test --parallel
Enter fullscreen mode Exit fullscreen mode

That is not glamorous, but that is the point. Local verification can be clever. CI should be dependable.

If you want to go further, a useful pattern is to refresh the TIA baseline on merges to main so developers pull fresh mapping data locally. That supports fast replay behavior without turning CI itself into a selective-execution system of record.

The High-Risk Changes Where Impacted-Only Is Not Enough

This is where teams need judgment instead of slogans.

Selective execution is strongest when the change is narrow and the dependency graph tells a clear story. It becomes weaker when the edit changes shared behavior, bootstrapping, or infrastructure that many tests rely on indirectly.

I would treat these as default escalation zones:

  • service providers and container bindings
  • authentication, authorization, policies, and middleware wiring
  • framework and package upgrades
  • global config changes and env-dependent behavior
  • migrations that alter heavily used tables or indexes
  • changes to base test classes, custom assertions, traits, or shared helpers
  • factories, seeders, and fixtures with broad reuse
  • queue, cache, event, broadcast, and notification infrastructure

These are not cases where TIA is useless. They are cases where TIA should be the first signal, not the last one.

Migration changes are especially deceptive

A schema change often looks narrower than it really is.

Suppose you rename a column on a commonly queried table, tweak a foreign key, or change default values that influence model state. The immediate impacted set may be small enough to look comforting. The real application blast radius may not be.

For that class of change, I would use a two-step rule:

  1. run impacted tests immediately for fast feedback
  2. widen to a broader parallel run before you trust the branch

That keeps the inner loop fast without mistaking local confidence for system-wide certainty.

Shared test infrastructure is another danger zone

If you change a custom test trait, base TestCase, helper that signs users in, or a fixture factory used across dozens of flows, the suite can fail in surprising places. Some of those failures may be obvious dependencies. Others are just side effects hiding behind convenience code.

That is also why test architecture quality still matters. TIA can reduce the runtime cost of a messy suite, but it cannot magically make a highly coupled suite easy to reason about.

Parallel Testing Is What Makes the Safety Net Practical

Impact analysis gets most of the attention because it is the shiny feature. For Laravel teams, parallel testing is what keeps the whole strategy honest.

If your fallback full run is still unbearable, people will avoid it. Then the workflow collapses into impacted-only by habit, even if nobody says that out loud.

Laravel’s official testing docs already give you the baseline: php artisan test --parallel, automatic per-process test databases, and hooks for process and database setup. Use them properly.

Resource isolation is where many teams fail

A lot of "parallel is flaky" complaints are not actually about parallelism. They are about shared resources that were never made safe for concurrency.

Typical offenders include:

  • cache prefixes shared across processes
  • temp files written to one global path
  • seeded data that assumes singleton state
  • external service doubles that reuse global ports or files
  • SQLite file contention when tests write concurrently

Laravel gives you the hooks to isolate this cleanly:

<?php

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\ParallelTesting;
use PHPUnit\Framework\TestCase;

ParallelTesting::setUpProcess(function (int $token) {
    config()->set('cache.prefix', 'tests_'.$token);
    config()->set('filesystems.disks.local.root', storage_path('framework/testing/'.$token));
});

ParallelTesting::setUpTestDatabase(function (string $database, int $token) {
    Artisan::call('db:seed', ['--class' => 'RequiredTestSeeder']);
});

ParallelTesting::setUpTestCase(function (int $token, TestCase $testCase) {
    // Per-test-case setup when specific shared resources need token isolation.
});
Enter fullscreen mode Exit fullscreen mode

That is the sort of implementation detail teams skip when they want the speedup without the engineering work. Then they conclude that the tool is unreliable. Usually the problem is that the suite was relying on hidden global state.

Profile before you over-engineer

Before inventing new layers, run Pest with --profile and look at the slowest tests. In many Laravel suites, a small number of pathological tests dominate total runtime.

Typical causes:

  • repeated full-database seeding inside unrelated tests
  • expensive external process setup
  • feature tests that cover too much workflow in one file
  • chatty factories creating large object graphs for trivial assertions
  • browser or integration tests mixed into the same default loop

Impact analysis helps with selection. It does not excuse waste inside the selected tests. Fixing the slowest ten often matters more than adding another clever test command.

What a Production-Ready Workflow Looks Like

The best rollout is not "turn on TIA and trust the magic." It is a layered workflow with escalation points.

Here is the version I would recommend to most PHP teams.

1. Default to impacted runs during active development

When a developer or agent is changing one service, one request class, one controller, or one policy, test:impact should be the default response. It keeps the inner loop tight and makes frequent verification realistic.

2. Widen the scope when the branch stops being narrow

Once the work touches shared infrastructure, data shape, or multiple bounded areas, run test:quick. That broader parallel pass catches issues the narrow loop was never meant to certify.

3. Keep full-suite CI as the merge truth

Protected branches should still rely on a full run from a clean checkout. If you blur that line, you lose the only signal everyone can trust equally.

4. Write the escalation rules down

Do not leave this to intuition alone. Put it in CONTRIBUTING.md or the team handbook.

Something this short is enough:

Use `test:impact` for local iteration on narrow changes.
Run `test:quick` before pushing changes that touch shared app behavior.
Run `test:full` locally after risky refactors, migrations, or shared test infrastructure changes.
CI remains the source of truth and always runs the full suite.
Enter fullscreen mode Exit fullscreen mode

That documentation matters because it preserves meaning. Fast green stays useful without becoming sloppy green.

5. Split categories if your suite is structurally mixed

If unit, feature, browser, and contract tests all live in one verification habit, selective testing still helps, but your team will keep mixing very different confidence levels together.

A better model is often:

  • unit and feature tests in the fast daily loop
  • browser or end-to-end tests as a separate, less frequent layer
  • heavy cross-service contract checks reserved for CI or targeted pre-merge runs

The point is not to produce a pretty pyramid diagram. The point is to stop asking one command to carry every kind of certainty.

The Decision Rule That Actually Works

If your Laravel suite is too slow, do not pick between "run everything" and "run less." That framing is weak.

The stronger approach is this:

  • use impact analysis to make local iteration fast enough to be habitual
  • use parallel broader runs to absorb uncertainty when the blast radius grows
  • use full-suite CI to protect the branch with a deterministic signal
  • treat cross-cutting changes as escalation cases, not normal cases

That is the decision rule I would give any PHP team building with agents, frequent refactors, or simply a codebase large enough that the old full-suite-only habit has become a drag.

Pest 5’s release notes are worth reading for the TIA model, and Laravel’s testing documentation remains the practical reference for parallel setup and database behavior. Pest’s CLI reference is also useful once you start tuning the workflow.

The memorable version is short: selective locally, exhaustive in CI, and skeptical whenever the change smells global.

That is how you get faster feedback without lowering the bar.


Read the full post on QCode: https://qcode.in/laravel-test-impact-analysis-for-slow-suites/

Top comments (0)