Laravel PHPUnit XML Complete Guide
|

Laravel phpunit.xml: The Senior Developer’s Configuration Guide

Reading Time: 6 minutes

Here’s a conversation that happens on every team, usually around month six of a project:

“Why are our tests taking 8 minutes? They used to run in 45 seconds.”

Nine times out of ten, the answer is sitting in phpunit.xml — or more precisely, in the decisions nobody made when it defaulted to something that worked for a small project and never got revisited.

This guide is about those decisions. Not what each attribute does — the PHPUnit documentation covers that. This is about the tradeoffs, the things that bite you at scale, and the configuration patterns that keep test suites fast on day 1000 as well as day one.

Covers: Laravel 12, PHPUnit 11/12.


The Default File — and What It Assumes About You

<?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 suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory suffix=".php">./app</directory>
        </include>
    </source>

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="PULSE_ENABLED" value="false"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
    </php>
</phpunit>

This default assumes you have a simple app, no MySQL-specific queries, a single developer, and no CI pipeline that injects its own environment variables. That’s a lot of assumptions. The rest of this guide walks through what to change and when.


env vs force — The One That Silently Breaks CI

Every <env> entry uses plain env. There’s a second variant most documentation skims over: force.

<!-- env: only sets the variable if it isn't already set -->
<env name="DB_DATABASE" value=":memory:"/>

<!-- force: always overrides, no matter what -->
<env name="DB_DATABASE" value=":memory:" force="true"/>

Here’s where this matters in practice. Your GitHub Actions or GitLab CI pipeline probably injects DB_HOSTDB_DATABASE, and DB_CONNECTION to point tests at a MySQL service container. If phpunit.xml uses plain <env>, those CI variables take precedence — and your config is silently ignored. Tests run against the wrong database, failures look completely unrelated to the actual problem, and nobody can figure out why CI behaves differently from local.

The rule: use force="true" for values that must be set a specific way for tests to be correct. Use plain <env> for values you’re happy for CI to override.

<!-- Preferences — CI can change these -->
<env name="MAIL_MAILER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>

<!-- Requirements — CI must not touch these -->
<env name="APP_ENV" value="testing" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>

The Database Decision — Everything Flows From This

This is the most impactful configuration choice in the file.

Option 1: In-Memory SQLite

<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

The fastest possible setup. No disk I/O, migrations run in milliseconds, a 500-test suite that takes 4 minutes against MySQL often runs in under 60 seconds. It’s the right default for most projects.

The problem is SQLite silently breaks when your app uses MySQL-specific features:

// These fail or behave differently on SQLite:
Post::whereFullText('body', 'search term')->get();    // MySQL full-text search
User::where('settings->notifications', true)->get();  // JSON column operators
// Also: ENUM types, DATE_FORMAT, ON CONFLICT, INSERT IGNORE, REGEXP

Before committing to SQLite, spend five minutes scanning your migrations and Eloquent models for raw queries. One whereRaw('JSON_EXTRACT(...)') anywhere means SQLite will break tests unpredictably and the error messages won’t tell you why.

Option 2: Real MySQL with RefreshDatabase

<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="laravel_testing"/>
<env name="DB_HOST" value="127.0.0.1"/>

Complete production parity. RefreshDatabase wraps each test in a transaction and rolls it back after — no data bleeds between tests.

abstract class TestCase extends BaseTestCase
{
    use RefreshDatabase;
}

You’re paying 3–5× the runtime compared to in-memory SQLite. On a large suite, that’s real time. But if your app uses MySQL-specific features extensively, this isn’t optional.

Option 3: The Hybrid — What Most Senior Devs Actually Do

Unit tests don’t need a database at all. Feature tests do. Split them:

// tests/Unit/TestCase.php — pure logic, no DB
abstract class UnitTestCase extends BaseTestCase
{
    // If a unit test needs the DB, something's wrong with the test
}

// tests/Feature/TestCase.php — full DB access
abstract class FeatureTestCase extends BaseTestCase
{
    use RefreshDatabase;
}

Unit tests run on every commit, under 10 seconds, against nothing. Feature tests run on PRs with a proper MySQL container. Fast feedback when you need it, full confidence before merge.


Three Attributes You’re Not Using But Should Be

<phpunit
    bootstrap="vendor/autoload.php"
    colors="true"
    failOnRisky="true"
    failOnWarning="true"
    failOnDeprecation="true"
>

failOnRisky="true" turns yellow warnings into red failures. A test is “risky” if it makes no assertions, produces output, or touches global state. By default those pass with a warning. But a test with no assertions is a bug — it gives you false confidence. This forces developers to either add assertions or explicitly mark the test with #[DoesNotPerformAssertions].

failOnWarning="true" catches PHPUnit’s own configuration and isolation warnings before they accumulate quietly in the background.

failOnDeprecation="true" is the most valuable one to add before any major upgrade. Run it before bumping Laravel or PHP versions and you’ll see every deprecated API in your codebase in a single pass. Your test suite becomes a free upgrade readiness report.

One practical note: don’t add all three at once to an existing codebase. You’ll drown in failures, get frustrated, and disable them. Start with failOnDeprecation, fix what it finds over a few weeks, then add the others one at a time.


BCRYPT_ROUNDS — Just Do the Maths Once

Production bcrypt cost is 12 rounds — about 250ms per hash. Test cost at 4 rounds is about 2ms.

If 60% of your 200 feature tests create a user: 120 tests × 248ms saved = 30 seconds just on password hashing. With cost 4, that drops to under 1 second.

Keep it at 4. You’re testing authentication logic, not the bcrypt library.


The <source> Block — Coverage That Actually Means Something

The default ./app includes middleware, service providers, and kernel files — framework wiring you didn’t write and shouldn’t be measuring. This inflates your coverage number and hides where you actually need more tests.

Measure your business logic specifically:

<source>
    <include>
        <directory suffix=".php">./app/Actions</directory>
        <directory suffix=".php">./app/Services</directory>
        <directory suffix=".php">./app/Models</directory>
        <directory suffix=".php">./app/Jobs</directory>
        <directory suffix=".php">./app/Notifications</directory>
        <directory suffix=".php">./app/Rules</directory>
    </include>
</source>

A 90% coverage score on App\Services means something. A 90% score that includes App\Http\Kernel.php doesn’t.

Set your coverage gate based on where you are now, not where you wish you were:

php artisan test --coverage --min=85

If you’re at 67%, set the gate at 65% and raise it monthly. A gate that fails the build on day one gets disabled on day two.


Parallel Testing — The Config Docs Don’t Fully Explain

php artisan test --parallel --processes=4

A 4-minute suite often drops to under 90 seconds with 4 processes. The catch: in-memory SQLite doesn’t work with parallel testing — multiple processes can’t share :memory:. You need per-process database isolation:

// AppServiceProvider::boot()
use Illuminate\Support\Facades\ParallelTesting;

ParallelTesting::setUpProcess(function (int $token) {
    config(['database.connections.sqlite.database' =>
        database_path("testing_{$token}.sqlite")
    ]);
});

ParallelTesting::setUpTestDatabase(function (string $database, int $token) {
    Artisan::call('migrate:fresh');
});

ParallelTesting::tearDownProcess(function (int $token) {
    $path = database_path("testing_{$token}.sqlite");
    if (file_exists($path)) unlink($path);
});

Each process gets its own SQLite file, runs its own migrations, and cleans up when it finishes.


The .env.testing Pattern for Teams

Setting everything in phpunit.xml works solo. On a team it creates three problems: developers override values with their local .env, CI credentials end up committed, and the XML grows unwieldy at 30+ variables.

The cleaner approach: three layers.

phpunit.xml — only the non-negotiables:

<php>
    <env name="APP_ENV" value="testing" force="true"/>
    <env name="QUEUE_CONNECTION" value="sync" force="true"/>
</php>

.env.testing — committed, the canonical test environment:

APP_ENV=testing
APP_KEY=base64:test-key-here
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_STORE=array
SESSION_DRIVER=array
MAIL_MAILER=array
QUEUE_CONNECTION=sync
BCRYPT_ROUNDS=4
TELESCOPE_ENABLED=false
PULSE_ENABLED=false

.env.testing.local — gitignored, per-developer overrides:

# I need real MySQL for JSON column features
DB_CONNECTION=mysql
DB_DATABASE=myapp_testing

Laravel loads .env.testing automatically when APP_ENV=testing. Local overrides live in a file that never touches the repo.


Custom Bootstrap for Edge Cases

The default bootstrap="vendor/autoload.php" covers 95% of projects. For the rest — clearing a stale config cache, setting a consistent timezone, registering test-only helpers:

<?php
// tests/bootstrap.php

require __DIR__ . '/../vendor/autoload.php';

// Prevents date-related flakiness across different developer timezones
date_default_timezone_set('UTC');

// Stale config cache causes confusing failures — clear it every run
if (file_exists(__DIR__ . '/../bootstrap/cache/config.php')) {
    unlink(__DIR__ . '/../bootstrap/cache/config.php');
}

require __DIR__ . '/helpers.php';
<phpunit bootstrap="tests/bootstrap.php">

Finding What’s Actually Slow

Before optimising anything, profile:

php artisan test --profile

In most suites, 20% of tests account for 80% of the runtime. Three most common culprits:

External HTTP calls that aren’t faked:

// Actually hits Stripe — 300ms+ per test
$stripe->charges->create([...]);

// Faked — microseconds
Http::fake(['api.stripe.com/*' => Http::response(['id' => 'ch_test'], 200)]);

RefreshDatabase on tests that never touch the DB:

class MoneyTest extends UnitTestCase // No database needed here
{
    public function test_converts_paise_to_rupees(): void
    {
        $money = new Money(10000, 'INR');
        $this->assertEquals(100.00, $money->toRupees());
    }
}

Factories creating far more data than the test needs:

// Creates 500 rows to check one total — wasteful
$user = User::factory()
    ->has(Order::factory()->count(50)->has(OrderItem::factory()->count(10)))
    ->create();

// Creates only what the assertion actually uses
$user   = User::factory()->create();
$orders = Order::factory()->count(3)->for($user)->create();

Upgrading from PHPUnit 9/10 to 11/12

Run this first — it flags deprecated config and often auto-fixes the file:

./vendor/bin/phpunit --migrate-configuration
Old (PHPUnit 9/10)Current (PHPUnit 11/12)
<coverage> element<source> element
CACHE_DRIVER env varCACHE_STORE
verbose="true" on <phpunit>--verbose CLI flag only
@covers docblock#[CoversClass] PHP attribute
withConsecutive()Removed in v12 — use withParameterSetsInOrder()
executionOrder="random"Now the default

The Production-Ready phpunit.xml

<?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="tests/bootstrap.php"
    colors="true"
    failOnRisky="true"
    failOnWarning="true"
    failOnDeprecation="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory suffix="Test.php">./tests/Integration</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory suffix=".php">./app/Actions</directory>
            <directory suffix=".php">./app/Services</directory>
            <directory suffix=".php">./app/Models</directory>
            <directory suffix=".php">./app/Jobs</directory>
            <directory suffix=".php">./app/Notifications</directory>
            <directory suffix=".php">./app/Rules</directory>
        </include>
    </source>

    <php>
        <env name="APP_ENV" value="testing" force="true"/>
        <env name="QUEUE_CONNECTION" value="sync" force="true"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <env name="PULSE_ENABLED" value="false"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
    </php>
</phpunit>

The CI Pipeline That Ties It Together

name: Tests
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pcov   # Faster than Xdebug for coverage
      - run: composer install --no-interaction
      - run: php artisan test --testsuite=Unit --coverage --min=85

  feature:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: laravel_testing
          MYSQL_ROOT_PASSWORD: secret
        options: --health-cmd="mysqladmin ping" --health-interval=10s
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install --no-interaction
      - run: php artisan test --testsuite=Feature --parallel --processes=4
        env:
          DB_CONNECTION: mysql
          DB_DATABASE: laravel_testing
          DB_HOST: 127.0.0.1
          DB_USERNAME: root
          DB_PASSWORD: secret

PHPUnit 13 dropped in February 2026 and Pest 4 is fully supported on Laravel 12 right now — with browser testing, sharding, and a lot more. What changed, what to upgrade, and what to wait on: PHPUnit 13 and Pest 4: What’s New and What to Do →


Last updated: March 2026 — Laravel 12, PHPUnit 11/12. Resources: PHPUnit docs · PHPUnit releases · Laravel testing docs

Laravel phpunit.xml: The Senior Developer's Configuration Guide

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One Comment