PHPackages                             mattiasgeniar/phpunit-query-count-assertions - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. mattiasgeniar/phpunit-query-count-assertions

ActiveLibrary[Testing &amp; Quality](/categories/testing)

mattiasgeniar/phpunit-query-count-assertions
============================================

A custom assertion for phpunit that allows you to count the amount of SQL queries used in a test. Can be used to enforce certain performance characteristics (ie: limit queries to X for a certain action).

1.3.2(2mo ago)160730.9k—3%9[1 PRs](https://github.com/mattiasgeniar/phpunit-query-count-assertions/pulls)2MITPHPPHP ^8.2CI passing

Since Sep 27Pushed 2mo ago5 watchersCompare

[ Source](https://github.com/mattiasgeniar/phpunit-query-count-assertions)[ Packagist](https://packagist.org/packages/mattiasgeniar/phpunit-query-count-assertions)[ Docs](https://github.com/mattiasgeniar/phpunit-query-count-assertions)[ GitHub Sponsors](https://github.com/mattiasgeniar)[ RSS](/packages/mattiasgeniar-phpunit-query-count-assertions/feed)WikiDiscussions master Synced 1mo ago

READMEChangelog (10)Dependencies (12)Versions (25)Used By (2)

PHP query count assertions for PHPUnit
======================================

[](#php-query-count-assertions-for-phpunit)

[![Latest Version on Packagist](https://camo.githubusercontent.com/36ec8143d48184c8d3706f2eff92369f1399d8f420af258a2aadc5ad3a331960/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d61747469617367656e6961722f706870756e69742d71756572792d636f756e742d617373657274696f6e732e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/mattiasgeniar/phpunit-query-count-assertions)[![Total Downloads](https://camo.githubusercontent.com/053ea0113f1c6aedd2bce22ffbc83660c3107f3675410d81fb8ce4f575f17a8f/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6d61747469617367656e6961722f706870756e69742d71756572792d636f756e742d617373657274696f6e732e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/mattiasgeniar/phpunit-query-count-assertions)[![Tests](https://camo.githubusercontent.com/0ab62d61c248b9c1d5e4436fed6a9e14f23f5b643263b349a9240a82ba19c285/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6d61747469617367656e6961722f706870756e69742d71756572792d636f756e742d617373657274696f6e732f72756e2d74657374732e796d6c3f6272616e63683d6d6173746572266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/mattiasgeniar/phpunit-query-count-assertions/actions/workflows/run-tests.yml)[![PHP Version](https://camo.githubusercontent.com/89820aff75b0a1eccd0a0f2fda816d953be98a3f7aa745bd82c6b9911ee22000/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6d61747469617367656e6961722f706870756e69742d71756572792d636f756e742d617373657274696f6e732e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/mattiasgeniar/phpunit-query-count-assertions)

Count and assert SQL queries in your tests. Catch N+1 problems, full table scans, duplicate queries, and slow queries before they hit production.

Supports Laravel, Doctrine/Symfony, and Phalcon.

Requirements
------------

[](#requirements)

- PHP 8.2+
- PHPUnit 11 or Pest 3
- **Laravel 11/12**, **Doctrine DBAL 4**, or **Phalcon 6+**

Driver Compatibility
--------------------

[](#driver-compatibility)

FeatureLaravelDoctrinePhalconQuery counting✅✅✅Query timing✅❌✅Duplicate detection✅✅✅Index analysis (EXPLAIN)✅✅✅Row count analysis✅✅✅Lazy loading detection✅❌❌**Note:** Lazy loading detection requires framework-specific hooks that only Laravel provides. Assertions like `assertNoLazyLoading()` will emit a warning on Doctrine and Phalcon and pass without checking, since violations cannot be detected.

**Note:** Doctrine's logging middleware only fires before query execution, so query timing is not available. Timing assertions (`assertMaxQueryTime`, `assertTotalQueryTime`) will emit a warning and pass without checking for Doctrine.

Installation
------------

[](#installation)

You can install the package via composer:

```
composer require --dev mattiasgeniar/phpunit-query-count-assertions
```

Quick start
-----------

[](#quick-start)

Add the trait, wrap your core logic with efficiency tracking:

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;

class CertificateHealthCheckTest extends TestCase
{
    use AssertsQueryCounts;

    public function test_health_checker_is_efficient(): void
    {
        // Setup - create test data (these queries aren't tracked)
        $certificate = Certificate::factory()->expired()->create();
        $run = new InMemoryRun();

        // Track only the code under test
        $this->trackQueries();
        app(CertificateHealthChecker::class)->perform($run);
        $this->assertQueriesAreEfficient();
    }
}
```

This catches N+1 queries, duplicate queries, and missing indexes in a single assertion. Your test setup (factories, seeders) stays outside the tracked block so it doesn't trigger false positives.

Framework Setup
---------------

[](#framework-setup)

### Laravel (auto-detected)

[](#laravel-auto-detected)

No configuration needed. The package auto-detects Laravel and uses `DB::listen()` for query tracking.

### Symfony

[](#symfony)

Symfony requires the logging middleware to be registered as a service. Add this to `config/packages/test/services.yaml` (this directory is only loaded when `APP_ENV=test`, so the middleware won't affect dev or production):

```
services:
    test.query_assertions.driver:
        class: Mattiasgeniar\PhpunitQueryCountAssertions\Drivers\DoctrineDriver
        public: true

    test.query_assertions.logger:
        class: Mattiasgeniar\PhpunitQueryCountAssertions\Drivers\DoctrineQueryLogger
        arguments:
            - '@test.query_assertions.driver'
            - 'default'

    test.query_assertions.middleware:
        class: Doctrine\DBAL\Logging\Middleware
        arguments:
            - '@test.query_assertions.logger'
        tags:
            - { name: doctrine.middleware }
```

Then in your tests:

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;
use Mattiasgeniar\PhpunitQueryCountAssertions\Drivers\DoctrineDriver;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

// For KernelTestCase (unit/integration tests)
class YourTest extends KernelTestCase
{
    use AssertsQueryCounts;

    protected function setUp(): void
    {
        parent::setUp();
        self::bootKernel();
        $this->setUpQueryAssertions();
    }

    private function setUpQueryAssertions(): void
    {
        $driver = self::getContainer()->get('test.query_assertions.driver');
        $connection = self::getContainer()->get('doctrine.dbal.default_connection');
        $driver->registerConnection('default', $connection);
        self::useDriver($driver);
    }

    public function test_queries(): void
    {
        $this->trackQueries();
        // ... your test code
        $this->assertQueryCountMatches(2);
    }
}
```

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

// For WebTestCase (functional/controller tests)
class YourControllerTest extends WebTestCase
{
    use AssertsQueryCounts;

    public function test_queries(): void
    {
        $client = static::createClient(); // Boots kernel automatically

        // Set up query assertions AFTER createClient()
        $driver = self::getContainer()->get('test.query_assertions.driver');
        $connection = self::getContainer()->get('doctrine.dbal.default_connection');
        $driver->registerConnection('default', $connection);
        self::useDriver($driver);

        $this->trackQueries();
        $client->request('GET', '/api/users');
        $this->assertQueryCountMatches(2);
    }
}
```

### Phalcon

[](#phalcon)

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;
use Mattiasgeniar\PhpunitQueryCountAssertions\Drivers\PhalconDriver;

class YourTest extends TestCase
{
    use AssertsQueryCounts;

    protected function setUp(): void
    {
        parent::setUp();

        // Get DB adapter from DI and register with driver
        $driver = new PhalconDriver();
        $driver->registerConnection('default', $this->getDI()->get('db'));

        self::useDriver($driver);
    }

    public function test_queries(): void
    {
        $this->trackQueries();
        // ... your test code
        $this->assertQueryCountMatches(2);
    }
}
```

### What it catches

[](#what-it-catches)

- **N+1 queries** — lazy loading violations
- **Duplicate queries** — same query executed multiple times
- **Missing indexes** — full table scans, unused indexes
- **Filesort &amp; temp tables** — common MySQL performance issues

When something fails, you get actionable output with the exact queries and their locations (file:line).

Query count assertions
----------------------

[](#query-count-assertions)

For cases where you need precise control over query counts:

```
// Exact count
$this->assertQueryCountMatches(2, fn() => $this->loadUserWithPosts());

// Upper bounds
$this->assertQueryCountLessThan(6, fn() => $this->fetchDashboard());

// No queries (cached?)
$this->assertNoQueriesExecuted(fn() => $this->getCachedData());

// Range
$this->assertQueryCountBetween(3, 7, fn() => $this->complexOperation());
```

Tracking queries across the entire test
---------------------------------------

[](#tracking-queries-across-the-entire-test)

If you need to count queries outside closures, initialize tracking in `setUp()`:

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;

class YourTest extends TestCase
{
    use AssertsQueryCounts;

    protected function setUp(): void
    {
        parent::setUp();

        $this->trackQueries();
    }

    public function test_queries_across_method_calls(): void
    {
        $this->step1();
        $this->step2();

        $this->assertQueryCountMatches(5);
    }
}
```

Multi-connection support
------------------------

[](#multi-connection-support)

By default, `trackQueries()` captures queries from **all database connections** — not just the default one. This is useful when your application uses read replicas, separate analytics databases, or tenant-specific connections.

```
// Track all connections (default)
$this->trackQueries();

DB::select('SELECT 1');                         // Tracked
DB::connection('replica')->select('SELECT 2');  // Also tracked

$queries = self::getQueriesExecuted();
// $queries[0]['connection'] === 'mysql'
// $queries[1]['connection'] === 'replica'
```

### Filtering to specific connections

[](#filtering-to-specific-connections)

You can optionally filter to only track specific connection(s):

```
// Track only the replica connection
$this->trackQueries('replica');

// Track multiple specific connections
$this->trackQueries(['mysql', 'replica']);
```

This is useful when:

- Your test setup runs queries on different connections that you don't want to count
- You want to verify that specific queries go to the right connection
- You're debugging connection routing in read/write split setups

Failure messages
----------------

[](#failure-messages)

Failed assertions show you the actual queries:

```
Expected 1 queries, got 3.
Queries executed:
  1. [0.45ms] SELECT * FROM users WHERE id = ?
      Bindings: [1]
      Locations:
        #1: tests/Feature/UserTest.php:42
  2. [0.32ms] SELECT * FROM posts WHERE user_id = ?
      Bindings: [1]
      Locations:
        #1: tests/Feature/UserTest.php:46
  3. [0.28ms] SELECT * FROM comments WHERE post_id IN (?, ?, ?)
      Bindings: [1, 2, 3]
      Locations:
        #1: tests/Feature/UserTest.php:50

```

Locations (file:line) are shown for each query when available. This applies to duplicate, index, row count, timing, and total time failures too.

Lazy loading / N+1 detection
----------------------------

[](#lazy-loading--n1-detection)

Uses Laravel's built-in lazy loading prevention:

```
// Fails if any lazy loading occurs
$this->assertNoLazyLoading(function () {
    $users = User::all();

    foreach ($users as $user) {
        $user->posts->count(); // N+1 query
    }
});

// Passes with eager loading
$this->assertNoLazyLoading(function () {
    $users = User::with('posts')->get();

    foreach ($users as $user) {
        $user->posts->count();
    }
});

// Assert specific number of violations
$this->assertLazyLoadingCount(2, function () {
    // ...
});
```

Output:

```
Lazy loading violations detected:
Violations:
  1. App\Models\User::$posts
  2. App\Models\User::$posts

```

**Note:** Laravel only triggers this when loading multiple models. Single model fetches won't trigger violations.

Index usage / full table scan detection
---------------------------------------

[](#index-usage--full-table-scan-detection)

Runs EXPLAIN on each query to detect performance issues:

```
$this->assertAllQueriesUseIndexes(function () {
    User::find(1); // Uses primary key, passes
});

$this->assertAllQueriesUseIndexes(function () {
    User::where('name', 'John')->get(); // Full table scan, fails
});
```

Output:

```
Queries with index issues detected:

  1. SELECT * FROM users WHERE name = ?
     Bindings: ["John"]
     Issues:
       - [ERROR] Full table scan on 'users'
     Locations:
       #1: tests/Feature/UserTest.php:42

```

### Supported databases

[](#supported-databases)

- **MySQL** (5.6+) - Full support with JSON EXPLAIN
- **MariaDB** - Full support with tabular EXPLAIN
- **SQLite** - Index analysis supported, row counting not available

Other databases will emit a warning and pass without checking. See [Custom analysers](#custom-analysers) to add support for additional databases.

### What gets analyzed

[](#what-gets-analyzed)

Only queries that support EXPLAIN are analyzed:

- SELECT queries
- UPDATE queries
- DELETE queries
- INSERT...SELECT queries
- REPLACE...SELECT queries

Plain INSERT, CREATE, DROP, and other DDL statements are skipped.

### Issue severity levels

[](#issue-severity-levels)

Issues are classified by severity and shown with prefixes in the output:

SeverityPrefixMeaningError`[ERROR]`Critical issues that almost always need fixing (full table scans, unused available indexes)Warning`[WARNING]`Potential issues that may be acceptable in some cases (filesort, temporary tables, full index scans)Info`[INFO]`Informational notes (low filter efficiency, co-routine usage)By default, only errors and warnings cause assertion failures. Informational issues are printed as `[INFO]` notices (non-failing) so they're visible even when tests pass.

### MySQL / MariaDB detects

[](#mysql--mariadb-detects)

- Full table scans (`type=ALL`)
- Full index scans (`type=index`)
- Index available but not used
- Using filesort
- Using temporary tables
- Using join buffer (missing index for joins)
- Full scan on NULL key
- Low filter efficiency (examining many rows, keeping few)
- High query cost (when threshold configured)

### SQLite detects

[](#sqlite-detects)

- Full table scans (`SCAN table`)
- Temporary B-tree usage for ORDER BY, DISTINCT, GROUP BY
- Co-routine subqueries
- **FK constraint checks** - When a DELETE/UPDATE triggers scans on related tables, the message includes FK details: ```
    [WARNING] Full table scan on 'posts' (FK constraint check: posts.user_id → users.id (ON DELETE CASCADE))

    ```

### Small table optimization

[](#small-table-optimization)

Full table scans, full index scans, and "index available but not used" warnings on tables with fewer than 10 rows are ignored by default, since scanning tiny tables is often faster than using an index. MySQL's docs note this is common for tables with fewer than 10 rows: . See [Configurable thresholds](#configurable-thresholds) to adjust this.

Duplicate query detection
-------------------------

[](#duplicate-query-detection)

Same query executed multiple times? You'll know:

```
$this->assertNoDuplicateQueries(function () {
    User::find(1);
    User::find(1); // Duplicate
});
```

Output:

```
Duplicate queries detected:

  1. Executed 2 times: SELECT * FROM users WHERE id = ?
     Bindings: [1]
     Locations:
       #1: tests/Feature/UserTest.php:42
       #2: tests/Feature/UserTest.php:43

```

**Note:** Different bindings = different queries. `User::find(1)` and `User::find(2)` are unique.

Row count threshold (MySQL / MariaDB only)
------------------------------------------

[](#row-count-threshold-mysql--mariadb-only)

```
$this->assertMaxRowsExamined(1000, function () {
    User::where('status', 'active')->get();
});
```

Output:

```
Queries examining more than 1000 rows:

  1. SELECT * FROM users WHERE status = ?
     Bindings: ["active"]
     Rows examined: 15000
     Locations:
       #1: tests/Feature/UserTest.php:42

```

SQLite doesn't provide row estimates in EXPLAIN QUERY PLAN, so a warning is emitted and the assertion passes without checking.

Query timing assertions
-----------------------

[](#query-timing-assertions)

```
// No single query over 100ms
$this->assertMaxQueryTime(100, function () {
    User::with('posts', 'comments')->get();
});

// Total time under 500ms
$this->assertTotalQueryTime(500, function () {
    $users = User::all();
    $posts = Post::where('published', true)->get();
    $stats = DB::select('SELECT COUNT(*) FROM analytics');
});
```

Output:

```
Queries exceeding 100ms:

  1. [245.32ms] SELECT * FROM users
     Locations:
       #1: tests/Feature/UserTest.php:42
  2. [102.15ms] SELECT * FROM posts WHERE published = ?
     Bindings: [true]
     Locations:
       #1: tests/Feature/UserTest.php:43

```

Combined efficiency assertion
-----------------------------

[](#combined-efficiency-assertion)

`assertQueriesAreEfficient()` checks everything at once: N+1, duplicates, and missing indexes. The [Quick start](#quick-start) shows the recommended inline pattern. Below are alternative approaches.

### With a closure

[](#with-a-closure)

```
$this->assertQueriesAreEfficient(function () {
    $users = User::with('posts')->get();

    foreach ($users as $user) {
        $user->posts->count();
    }
});
```

### Pest: beforeEach()

[](#pest-beforeeach)

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;

uses(AssertsQueryCounts::class);

beforeEach(function () {
    $this->trackQueries();
});

it('loads the dashboard efficiently', function () {
    $this->get('/dashboard');

    $this->assertQueriesAreEfficient();
});

it('processes orders without N+1', function () {
    $order = Order::factory()->create();

    $this->post("/orders/{$order->id}/process");

    $this->assertQueriesAreEfficient();
});
```

### PHPUnit: setUp()

[](#phpunit-setup)

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;
use Tests\TestCase;

class DashboardTest extends TestCase
{
    use AssertsQueryCounts;

    protected function setUp(): void
    {
        parent::setUp();

        $this->trackQueries();
    }

    public function test_dashboard_loads_efficiently(): void
    {
        $this->get('/dashboard');

        $this->assertQueriesAreEfficient();
    }

    public function test_order_processing_has_no_n_plus_one(): void
    {
        $order = Order::factory()->create();

        $this->post("/orders/{$order->id}/process");

        $this->assertQueriesAreEfficient();
    }
}
```

### Paranoid mode (automatic checks on every test)

[](#paranoid-mode-automatic-checks-on-every-test)

Want to automatically check every test for query efficiency issues? You can use `afterEach()` hooks to run assertions globally. This is aggressive and may surface many issues - use with caution.

**Pest (in `tests/Pest.php`):**

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;

pest()->extend(Tests\TestCase::class)
    ->use(AssertsQueryCounts::class)
    ->beforeEach(fn () => self::trackQueries())
    ->afterEach(fn () => $this->assertQueriesAreEfficient())
    ->in('Feature');
```

**PHPUnit (base test class):**

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;

abstract class TestCase extends BaseTestCase
{
    use AssertsQueryCounts;

    protected function setUp(): void
    {
        parent::setUp();
        $this->trackQueries();
    }

    protected function tearDown(): void
    {
        $this->assertQueriesAreEfficient();
        parent::tearDown();
    }
}
```

This will fail any test that has N+1 queries, duplicate queries, or missing indexes. Consider starting with a subset of tests rather than your entire suite.

### Opting out with `#[DisableQueryTracking]`

[](#opting-out-with-disablequerytracking)

In paranoid mode, some tests may need to opt out — for example, tests with heavy seeders, migrations, or tests that intentionally execute many queries. Use the `#[DisableQueryTracking]` attribute to skip tracking for specific tests or entire classes:

```
use Mattiasgeniar\PhpunitQueryCountAssertions\Attributes\DisableQueryTracking;

class DashboardTest extends TestCase
{
    use AssertsQueryCounts;

    protected function setUp(): void
    {
        parent::setUp();
        $this->trackQueries();
    }

    protected function tearDown(): void
    {
        $this->assertQueriesAreEfficient();
        parent::tearDown();
    }

    // This test is checked normally
    public function test_dashboard_loads_efficiently(): void
    {
        $this->get('/dashboard');
    }

    // This test opts out of query tracking
    #[DisableQueryTracking]
    public function test_heavy_seeder_setup(): void
    {
        $this->seed(LargeDatasetSeeder::class);
        // ...
    }
}
```

You can also disable tracking for an entire test class:

```
use Mattiasgeniar\PhpunitQueryCountAssertions\Attributes\DisableQueryTracking;

#[DisableQueryTracking]
class MigrationTest extends TestCase
{
    use AssertsQueryCounts;

    // All tests in this class skip query tracking
}
```

When `#[DisableQueryTracking]` is present, `trackQueries()` returns early without setting up listeners, and all assertions (`assertQueriesAreEfficient()`, `assertQueryCountMatches()`, etc.) pass silently.

Configurable thresholds
-----------------------

[](#configurable-thresholds)

### MySQL analyser options

[](#mysql-analyser-options)

The MySQL analyser has configurable thresholds that can be set by registering a customized instance:

```
use Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts;
use Mattiasgeniar\PhpunitQueryCountAssertions\QueryAnalysers\MySQLAnalyser;

class YourTest extends TestCase
{
    use AssertsQueryCounts;

    protected function setUp(): void
    {
        parent::setUp();

        // Flag full table scans only on tables with 500+ rows (default: 10)
        self::registerQueryAnalyser(
            (new MySQLAnalyser)->withMinRowsForScanWarning(500)
        );

        // Also flag queries with cost above threshold
        self::registerQueryAnalyser(
            (new MySQLAnalyser)
                ->withMinRowsForScanWarning(500)
                ->withMaxCost(1000.0)
        );
    }
}
```

MethodDefaultDescription`withMinRowsForScanWarning(int)`10Minimum rows to flag full table scans, full index scans, and unused index warnings`withMaxCost(float)`null (disabled)Maximum query cost before flagging as a warningCustom analysers
----------------

[](#custom-analysers)

Add support for additional databases by implementing the `QueryAnalyser` interface:

```
use Mattiasgeniar\PhpunitQueryCountAssertions\Contracts\ConnectionInterface;
use Mattiasgeniar\PhpunitQueryCountAssertions\QueryAnalysers\QueryAnalyser;
use Mattiasgeniar\PhpunitQueryCountAssertions\QueryAnalysers\QueryIssue;
use Mattiasgeniar\PhpunitQueryCountAssertions\QueryAnalysers\Concerns\ExplainsQueries;

class PostgreSQLAnalyser implements QueryAnalyser
{
    use ExplainsQueries; // Provides canExplain() for SELECT, UPDATE, DELETE, INSERT...SELECT

    public function supports(string $driver): bool
    {
        return $driver === 'pgsql';
    }

    public function explain(ConnectionInterface $connection, string $sql, array $bindings): array
    {
        return $connection->select('EXPLAIN (FORMAT JSON) ' . $sql, $bindings);
    }

    public function analyzeIndexUsage(array $explainResults, ?string $sql = null, ?ConnectionInterface $connection = null): array
    {
        $issues = [];

        // Parse PostgreSQL EXPLAIN JSON output
        // Look for "Seq Scan" nodes (full table scans)
        // Return QueryIssue instances for problems found
        // Use $sql to detect FK constraint checks (see SQLiteAnalyser for example)

        return $issues;
    }

    public function supportsRowCounting(): bool
    {
        return true; // PostgreSQL provides row estimates
    }

    public function getRowsExamined(array $explainResults): int
    {
        // Sum up "Plan Rows" from EXPLAIN output
        return 0;
    }
}
```

Register your custom analyser in your test's `setUp()`:

```
protected function setUp(): void
{
    parent::setUp();

    self::registerQueryAnalyser(new PostgreSQLAnalyser);
}
```

Custom analysers are checked before the built-in MySQL and SQLite analysers.

Helper methods
--------------

[](#helper-methods)

These methods let you inspect query data for custom assertions or debugging:

```
// Get all executed queries with their SQL, bindings, timing, and connection
$queries = self::getQueriesExecuted();
// Returns: [['query' => 'SELECT...', 'bindings' => [...], 'time' => 0.45, 'connection' => 'mysql'], ...]

// Get total number of queries executed
$count = self::getQueryCount();

// Get lazy loading violations from the last assertion
$violations = self::getLazyLoadingViolations();
// Returns: [['model' => 'App\Models\User', 'relation' => 'posts'], ...]

// Get detailed EXPLAIN results from the last index analysis
$results = self::getIndexAnalysisResults();
// Returns: [['query' => '...', 'bindings' => [...], 'issues' => [...], 'explain' => [...]], ...]

// Get duplicate queries from the last check
$duplicates = self::getDuplicateQueries();
// Returns: ['key' => ['count' => 2, 'query' => '...', 'bindings' => [...], 'locations' => [['file' => '...', 'line' => 123]]], ...]

// Get total query execution time in milliseconds
$totalTime = self::getTotalQueryTime();
```

Testing
-------

[](#testing)

```
composer test
```

License
-------

[](#license)

The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

###  Health Score

64

—

FairBetter than 99% of packages

Maintenance84

Actively maintained with recent releases

Popularity54

Moderate usage in the ecosystem

Community22

Small or concentrated contributor base

Maturity77

Established project with proven stability

 Bus Factor1

Top contributor holds 77.9% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~86 days

Recently: every ~8 days

Total

24

Last Release

81d ago

Major Versions

0.5 → 1.02020-09-28

PHP version history (5 changes)0.1PHP ^7.4

0.2PHP ^7.3

0.5PHP ^7.3|^7.4

1.1PHP ^7.3|^7.4|^8.0

1.1.6PHP ^8.2

### Community

Maintainers

![](https://www.gravatar.com/avatar/88829f29c843c975f4029171cccadbe74025a04dcf6541020b52c77396f22d20?d=identicon)[mattiasgeniar](/maintainers/mattiasgeniar)

---

Top Contributors

[![mattiasgeniar](https://avatars.githubusercontent.com/u/407270?v=4)](https://github.com/mattiasgeniar "mattiasgeniar (102 commits)")[![freekmurze](https://avatars.githubusercontent.com/u/483853?v=4)](https://github.com/freekmurze "freekmurze (19 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (4 commits)")[![laravel-shift](https://avatars.githubusercontent.com/u/15991828?v=4)](https://github.com/laravel-shift "laravel-shift (4 commits)")[![danijelk](https://avatars.githubusercontent.com/u/580753?v=4)](https://github.com/danijelk "danijelk (1 commits)")[![marijoo](https://avatars.githubusercontent.com/u/360736?v=4)](https://github.com/marijoo "marijoo (1 commits)")

---

Tags

testingsymfonylaraveldoctrinephalconqueriesmattiasgeniarphpunit-query-count-assertions

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/mattiasgeniar-phpunit-query-count-assertions/health.svg)

```
[![Health](https://phpackages.com/badges/mattiasgeniar-phpunit-query-count-assertions/health.svg)](https://phpackages.com/packages/mattiasgeniar-phpunit-query-count-assertions)
```

###  Alternatives

[dama/doctrine-test-bundle

Symfony bundle to isolate doctrine database tests and improve test performance

1.2k37.2M144](/packages/dama-doctrine-test-bundle)[behat/behat

Scenario-oriented BDD framework for PHP

4.0k96.8M2.0k](/packages/behat-behat)[zenstruck/foundry

A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.

78611.9M97](/packages/zenstruck-foundry)[osteel/openapi-httpfoundation-testing

Validate HttpFoundation requests and responses against OpenAPI (3+) definitions

1201.9M6](/packages/osteel-openapi-httpfoundation-testing)[lastzero/test-tools

Increases testing productivity by adding a service container and self-initializing fakes to PHPUnit

2244.3k13](/packages/lastzero-test-tools)[davestewart/sketchpad

An innovative front-end environment for interactive Laravel development

29512.9k1](/packages/davestewart-sketchpad)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
