DEV Community

Cover image for Solving Flaky Session Tests in Laravel: From Chaos to Stability
Dmitry Isaenko
Dmitry Isaenko

Posted on

Solving Flaky Session Tests in Laravel: From Chaos to Stability

The Problem: Flaky Tests That Drive You Crazy

Picture this: You're building a multi-tenant Laravel CRM with custom session tracking. You have a UserSession model that stores active company context, last activity, and device info. You write tests. They pass. You run them again. They fail. You run them a third time. They pass.

Welcome to flaky test hell πŸ”₯

Understanding the Root Cause

In our application, we have middleware that checks for active user sessions:

// CheckSessionExists middleware
public function handle($request, Closure $next)
{
    if (Auth::check()) {
        $sessionExists = UserSession::where('user_id', Auth::id())
            ->where('session_id', session()->getId())
            ->exists();

        if (!$sessionExists) {
            Auth::logout();
            return redirect()->route('login');
        }
    }

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Simple, right? But when testing features protected by this middleware, our tests would randomly fail because the UserSession record couldn't be found.

First Attempt: The Naive Approach

function actingAsWithSession($user)
{
    $sessionId = Str::random(40);
    session()->setId($sessionId);
    session()->start();

    test()->actingAs($user);

    DB::table('user_sessions')->insert([
        'user_id' => $user->id,
        'session_id' => $sessionId,
        'active_company_id' => null,
        'ip_address' => '127.0.0.1',
        'last_activity' => now(),
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    return $sessionId;
}
Enter fullscreen mode Exit fullscreen mode

Why This Failed

it('can access protected route', function () {
    $user = User::factory()->create();
    actingAsWithSession($user); // Creates session with ID "abc123"

    $response = $this->get('/dashboard'); // Laravel creates NEW session!

    $response->assertOk(); // FAILS - middleware can't find UserSession
});
Enter fullscreen mode Exit fullscreen mode

The Issue: Each HTTP request ($this->get(), $this->post(), etc.) in Pest/PHPUnit creates a fresh application instance. Without proper session persistence, Laravel generates a new session ID, making our UserSession record orphaned.

Second Attempt: Static Session ID

function actingAsWithSession($user)
{
    static $staticSessionId = null;

    if ($staticSessionId === null) {
        $staticSessionId = Str::random(40);
    }

    session()->setId($staticSessionId);
    session()->start();
    test()->actingAs($user);

    session()->save(); // Key addition!

    UserSession::updateOrCreate(
        ['session_id' => $staticSessionId],
        [
            'user_id' => $user->id,
            'active_company_id' => null,
            'ip_address' => '127.0.0.1',
            'last_activity' => now(),
        ]
    );

    return $staticSessionId;
}
Enter fullscreen mode Exit fullscreen mode

The Breakthrough (and New Problem)

Adding session()->save() was crucial - it forces Laravel to persist session data to the configured driver (file/redis/database).

But now we hit a different wall:

it('admin and user can coexist', function () {
    $admin = User::factory()->admin()->create();
    $user = User::factory()->create();

    actingAsWithSession($admin); // Sets static session ID "xyz789"
    actingAsWithSession($user);  // REUSES SAME "xyz789"!

    // Both users now share the same session - chaos!
});
Enter fullscreen mode Exit fullscreen mode

Problem: The static variable is shared across the entire test suite. Multiple users = session collision.

Final Solution: User-Scoped Session Management

function actingAsWithSession($user)
{
    // Key insight: Index sessions by user ID
    static $sessionIds = [];

    if (!isset($sessionIds[$user->id])) {
        $sessionIds[$user->id] = Str::random(40);
    }

    $sessionId = $sessionIds[$user->id];

    session()->setId($sessionId);
    session()->start();

    test()->actingAs($user);

    // CRITICAL: Add data to ensure session is written
    session()->put('auth_check', true);
    session()->save();

    UserSession::updateOrCreate(
        ['session_id' => $sessionId],
        [
            'user_id' => $user->id,
            'ip_address' => '127.0.0.1',
            'last_activity' => now(),
        ]
    );

    return $sessionId;
}
Enter fullscreen mode Exit fullscreen mode

Why This Works

  1. User Isolation: Each user gets their own session ID stored in $sessionIds[$user->id]
  2. Session Persistence: Calling session()->save() writes to the driver
  3. Data Guarantee: Adding a marker (auth_check) ensures non-empty session data gets saved
  4. Eloquent Integration: Using updateOrCreate() instead of raw query handles updates gracefully

Real-World Usage

it('redirects blocked users from dashboard', function () {
    $user = User::factory()->blocked()->create();
    actingAsWithSession($user);

    $response = $this->get(route('dashboard'));

    $response->assertRedirect(route('user_blocked'));
});

it('allows multiple user contexts in same test', function () {
    $admin = User::factory()->admin()->create();
    $user = User::factory()->create();

    // Each gets their own session
    actingAsWithSession($admin);
    $adminSession = session()->getId();

    actingAsWithSession($user);
    $userSession = session()->getId();

    expect($adminSession)->not->toBe($userSession);
});
Enter fullscreen mode Exit fullscreen mode

Critical Configuration: phpunit.xml

Don't forget to set the session driver correctly:

<php>
    <env name="SESSION_DRIVER" value="file"/>
    <!-- NOT "array" - file/redis/database ensure persistence -->
</php>
Enter fullscreen mode Exit fullscreen mode

Using array driver in tests will cause sessions to not persist between requests!

Results

  • βœ… 100% test pass rate (previously ~60-70% due to flakiness)
  • βœ… Stable CI/CD pipeline (no more random failures)
  • βœ… Multi-user test scenarios work reliably
  • βœ… Team confidence in test suite restored

Key Takeaways

  1. Understand Laravel's test lifecycle: Each HTTP request creates a new app instance
  2. Session persistence matters: Always session()->save() when testing session-dependent features
  3. Scope your static data: Use arrays indexed by entity ID, not single static values
  4. Choose the right driver: File/redis/database drivers persist; array driver doesn't
  5. Test determinism: If a test passes sometimes and fails others, you have a state management issue

Bonus: Testing Custom Middleware

With stable sessions, testing our session-checking middleware became trivial:

it('logs out users with invalid sessions', function () {
    $user = User::factory()->create();
    test()->actingAs($user); // NO UserSession record

    $response = $this->get('/dashboard');

    $response->assertRedirect(route('login'));
    expect(Auth::check())->toBeFalse();
});

it('allows users with valid sessions', function () {
    $user = User::factory()->create();
    actingAsWithSession($user); // CREATES UserSession

    $response = $this->get('/dashboard');

    $response->assertOk();
    expect(Auth::check())->toBeTrue();
});
Enter fullscreen mode Exit fullscreen mode

Resources


Have you encountered similar testing challenges? Share your war stories in the comments! πŸ‘‡

This article is based on real debugging experience in a production Laravel 12 + Vue 3 + Inertia.js CRM system. The complete test suite is available in our open-source project.

Top comments (0)