DEV Community

Cover image for Hidden Issue with whereFulltext and RefreshDatabase in Laravel
Ivan Mykhavko
Ivan Mykhavko

Posted on

1

Hidden Issue with whereFulltext and RefreshDatabase in Laravel

Understanding whereFulltext in Laravel

The whereFulltext method in Laravel allows developers to perform full-text searches on columns indexed with FULLTEXT in MySQL and PostgreSQL(docs). This method is useful for searching large datasets efficiently, especially for text-based searches. However, while it enhances search performance, it has some caveats, particularly when used in tests with RefreshDatabase.

The Role of RefreshDatabase in Testing

RefreshDatabase is a commonly used Laravel testing trait that ensures the database is migrated and reset before each test. It works by wrapping each test in a database transaction, which is rolled back after the test completes. This ensures database isolation between tests, preventing one test from affecting another(more).

However, this transactional approach poses a significant issue when working with full-text indexes.

The Issue: FULLTEXT Indexes and Transactions

Consider the following search implementation using whereFulltext:

public function search(string $query): array
{
    return Product::query()
        ->select(['id'])
        ->whereFulltext(['article'], $query)
        ->toBase()
        ->pluck('id')
        ->toArray();
}
Enter fullscreen mode Exit fullscreen mode

A basic test for this search method might look like this:

public function test_search_internal_brand_details(): void
{
    $this->actingAsFrontendUser();

    $product = $this->createProduct();
    $article = $product->article;

    $response = $this->postJson(route('api-v2:search.details'), [
        'article' => $article
    ]);

    $response->assertOk();
    $response->assertJsonPath('data.0.article', $product->article);
    $response->assertJsonPath('data.0.brand_id', $product->brand_id);
}
Enter fullscreen mode Exit fullscreen mode

This test, however, fails because the search query returns an empty result.

The root cause lies in how MySQL handles FULLTEXT indexes. MySQL does not support FULLTEXT indexes inside transactions (see MySQL documentation). Since RefreshDatabase wraps each test in a transaction, the full-text index is not accessible during the test.

Workarounds

Committing the Transaction

One possible solution is to commit the transaction before executing the search:

public function test_search_internal_brand_details(): void
{
    $this->actingAsFrontendUser();

    $product = $this->createProduct();
    DB::commit();

    $article = $product->article;

    $response = $this->postJson(route('api-v2:search.details'), [
        'article' => $article
    ]);

    $response->assertOk();
    $response->assertJsonPath('data.0.article', $product->article);
    $response->assertJsonPath('data.0.brand_id', $product->brand_id);

    Product::query()->truncate();
}
Enter fullscreen mode Exit fullscreen mode

While this works, it is not an ideal approach since manually committing and truncating data increases complexity and breaks test isolation.

Better Approach: Using a Search Repository

A cleaner and more maintainable solution is to abstract the search logic into a repository and mock it in tests.

Define a search repository interface:

interface ProductSearchRepositoryInterface
{
    public function search(string $query): array;
}
Enter fullscreen mode Exit fullscreen mode

Implement the repository using whereFulltext:

final class ProductSearchDatabaseRepository implements ProductSearchRepositoryInterface
{
    public function search(string $query): array
    {
        return Product::query()
            ->select(['id'])
            ->whereFulltext(['article'], $query)
            ->toBase()
            ->pluck('id')
            ->toArray();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, modify the test to mock the repository:

public function test_search_internal_brand_details(): void
{
    $this->actingAsFrontendUser();

    $product = $this->createProduct();

    $productSearchRepository = $this->createMock(ProductSearchRepositoryInterface::class);
    $productSearchRepository->method('search')->willReturn([$product->id]);

    $this->app->instance(ProductSearchRepositoryInterface::class, $productSearchRepository);

    $article = $product->article;

    $response = $this->postJson(route('api-v2:search.details'), [
        'article' => $article
    ]);

    $response->assertOk();
    $response->assertJsonPath('data.0.article', $product->article);
    $response->assertJsonPath('data.0.brand_id', $product->brand_id);
}
Enter fullscreen mode Exit fullscreen mode

By mocking the search repository, the test avoids dealing with FULLTEXT index limitations, making it more reliable and maintainable.

Conclusion

Using whereFulltext in Laravel tests with RefreshDatabase can lead to unexpected issues due to MySQL's handling of FULLTEXT indexes inside transactions. While committing transactions manually can resolve the problem, a better approach is to use dependency injection and mock the search logic. This ensures test reliability and maintains proper isolation between test cases.

API Trace View

How I Cut 22.3 Seconds Off an API Call with Sentry 👀

Struggling with slow API calls? Dan Mindru walks through how he used Sentry's new Trace View feature to shave off 22.3 seconds from an API call.

Get a practical walkthrough of how to identify bottlenecks, split tasks into multiple parallel tasks, identify slow AI model calls, and more.

Read more →

Top comments (0)

The best way to debug slow web pages cover image

The best way to debug slow web pages

Tools like Page Speed Insights and Google Lighthouse are great for providing advice for front end performance issues. But what these tools can’t do, is evaluate performance across your entire stack of distributed services and applications.

Watch video