Looking to hire Laravel developers? Try LaraJobs

explain-lint-laravel maintained by jeytekdev

Description
Laravel bridge for jeytekdev/explain-lint — captures queries via DB::listen() and provides a service provider with auto-discovery.
Author
Last update
2026/09/28 20:32 (dev-main)
License
Downloads
1

Comments
comments powered by Disqus

jeytekdev/explain-lint-laravel

Laravel bridge for jeytekdev/explain-lint — re-runs EXPLAIN against every query your test suite executes, and fails the build on full table scans, lost indexes, filesort and temporary tables.

Install (2 minutes)

composer require --dev jeytekdev/explain-lint-laravel

The service provider auto-discovers via extra.laravel.providers — nothing to register manually. It hooks DB::listen() and only captures queries while $app->runningUnitTests() is true (or EXPLAIN_LINT_FORCE=true), so there's no overhead outside your test suite.

Wire up the PHPUnit extension:

vendor/bin/explain-lint explain-lint:install

Run this from your project root (where composer.json/vendor/ live) — every path it touches (phpunit.xml, the new explain-lint.php) is resolved relative to the current working directory, not to vendor/bin/.

Or add it manually to phpunit.xml:

<extensions>
    <bootstrap class="ExplainLint\PHPUnit\ExplainLintExtension">
        <parameter name="config" value="explain-lint.php"/>
    </bootstrap>
</extensions>

Set the connection driver to match your database in the generated explain-lint.php:

'connections' => [
    'default' => [
        'driver' => 'mysql', // or 'pgsql'
    ],
],

Then run your suite as usual — no other code changes needed:

vendor/bin/phpunit
# or, with Pest:
vendor/bin/pest

Reading the report

A violation is printed as a block, grouped by test, with the info needed to act on it — including the fingerprint you'd copy into an allowlist entry:

explain-lint found 1 issue(s) in 1 test(s):

Tests\Feature\OrdersTest::test_pending_orders_are_listed
  [error] Full table scan on orders
      table:       orders
      rows:        48213
      query:       select * from orders where status = ?
      hint:        Add an index covering the query's WHERE/JOIN/ORDER BY columns, or check
                   why an existing index isn't used (leading wildcard LIKE, a function/cast
                   on the column, implicit type mismatch).
      fingerprint: 4f6a1c3e9d2b7a805e4f1c9b6d3a2e7f8c0b1a5d
  • In mode => 'warn' this is informational only — the build stays green.
  • In mode => 'strict', any [error]-severity violation fails the run.

Allowlisting a known-OK query

Two ways, both in explain-lint.php, both require a non-empty reason:

// Every violation on this table, regardless of query:
'allowlist' => [
    'audit_log' => 'Intentional full scan for the nightly export — JIRA-123',
],

// One specific query, by the fingerprint shown in the report above:
'allowlist_fingerprints' => [
    '4f6a1c3e9d2b7a805e4f1c9b6d3a2e7f8c0b1a5d' => 'Known slow report query — JIRA-456',
],

Optional: per-test assertions

If you'd rather assert explicitly inside a specific test instead of relying on suite-wide enforcement (or you're on a PHPUnit version predating the Extension/Event API), use the testing trait:

use ExplainLint\Laravel\Testing\ExplainLintTesting;

final class CheckoutTest extends TestCase
{
    use ExplainLintTesting;

    public function test_checkout_query_has_no_regressions(): void
    {
        $this->get('/checkout');

        $this->assertNoQueryRegressions();
    }
}

CI

Relying solely on the in-process exit(1) from the PHPUnit run is a single point of failure if anything in your pipeline swallows PHPUnit's exit code — run the check as a separate second step:

- run: vendor/bin/phpunit
- run: vendor/bin/explain-lint explain-lint:check

explain-lint:check needs a JUnit report to read, so enable it in config:

'report' => [
    'junit' => 'build/explain-lint.xml',
],

Troubleshooting

A table with very few rows still gets flagged as error. Table-size-based suppression (tiny/small tables never trigger scan rules) relies on information_schema.TABLES.TABLE_ROWS on MySQL, which is a cached estimate — on a freshly migrated test database it's often NULL until ANALYZE TABLE runs, and an unknown size is treated as "could be large" on purpose (so real regressions aren't silently hidden by a stale stat). If this shows up a lot on your test DB, either run ANALYZE TABLE <name> as part of your test setup, or allowlist the specific table/query.

Could not locate the Composer autoloader when running vendor/bin/explain-lint. If the package was installed via a path repository, vendor/jeytekdev/explain-lint is a symlink — this is resolved correctly as of the current release, but if you're on an older checkout, update jeytekdev/explain-lint.

Connection resolution. DB::listen() fires with the query's connectionName, matched against connections.<name> in your config, falling back to connections.default if there's no exact match — you don't need one entry per Laravel connection unless you actually want different thresholds per connection.

License

MIT