Summer Sale! All accounts are 50% off this week.

leknoppix-708669's avatar

Laravel PHPUnit in CI/CD: Every Route Returns 404 During Tests

🔴 Critical issue

Local test suite: ✅ All tests pass

CI (GitLab + Docker):120 failures out of 165 tests — almost every HTTP route returns 404

44 tests still pass, but there is no obvious pattern explaining why.


Project context

  • Framework: Laravel 13.8
  • PHP: 8.3
  • Docker image: PHP 8.3 (FPM, non-CLI)
  • CI: GitLab CI
  • Database: SQLite (:memory:)
  • PHPUnit: 12.x

Observed behavior

Test summary

Results: 120 failed, 1 risky, 44 passed (216 assertions)
Duration: 3.33s

Typical failures

✗ test_login_screen_can_be_rendered
Expected response status code [200] but received 404.

✗ test_users_can_authenticate_using_the_login_screen
Expected response status code [200] but received 404.

✗ test_admin_can_update_user
Expected response status code [201, 301, 302, 303, 307, 308] but received 404.

✗ test_admin_can_delete_user
Expected response status code [201, 301, 302, 303, 307, 308] but received 404.

✗ test_manager_cannot_access_create_form
Expected response status code [403] but received 404.

Affected routes

  • GET /login → 404
  • POST /login → 404
  • GET /users → 404
  • PATCH /users/{id} → 404
  • DELETE /users/{id} → 404
  • GET /organisms → 404
  • ...and almost every other application route.

Diagnostics already performed (all confirmed)

Item Status Details
Database migrations All tables are created successfully
Route registration php artisan route:list shows every expected route
File permissions Storage and cache directories are writable
PHP extensions SQLite, mbstring, intl, zip, gd, etc. are installed
Composer installation composer install completes successfully
Laravel bootstrap Application boots normally with no exceptions or stack traces

Current GitLab CI configuration

.php_setup: &php_setup
  before_script:
    - apt-get update
    - apt-get install -y git curl zip unzip libsqlite3-dev libzip-dev libicu-dev libonig-dev libxml2-dev libpng-dev libjpeg-dev libfreetype-dev
    - docker-php-ext-install pdo_sqlite mbstring intl bcmath gd zip
    - docker-php-ext-enable zip
    - curl -sS getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install

tests:
  <<: *php_setup
  stage: test
  script:
    - cp .env.example .env
    - php artisan key:generate
    - mkdir -p bootstrap/cache storage/logs storage/framework/{cache/data,sessions,views}
    - chmod -R 777 bootstrap/cache storage database
    - touch database/database.sqlite
    - php artisan migrate --no-interaction
    - php artisan test --debug

Current phpunit.xml

<env name="APP_ENV" value="testing"/>
<env name="CACHE_STORE" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

Troubleshooting attempts

Attempt Result
Run php artisan migrate --no-interaction before tests ❌ No improvement
Use CACHE_STORE=array and SESSION_DRIVER=array ❌ Slightly worse
Run php artisan config:clear and route:clear ❌ No effect
Recreate bootstrap/cache and fix permissions ❌ No effect
Create database/database.sqlite before migrations ❌ No effect
Switch from :memory: to database/database.sqlite ❌ No change

Pattern analysis

Key observations

  1. Every failing request returns 404, regardless of the expected response.
  2. 44 tests still pass, which suggests that:
    • Laravel boots successfully.
    • The service container is working.
    • Database migrations complete successfully.
    • Some HTTP requests are routed correctly.
  3. Even basic framework routes such as /login return 404.
  4. No Laravel exceptions or stack traces are produced.
  5. The test suite completes quickly (around 3 seconds), so this does not appear to be a timeout issue.

Current hypotheses

H1: Routes exist (route:list) but are not actually registered when PHPUnit executes.

H2: The router is only partially initialized, causing some routes to resolve while most do not.

H3: A service provider silently fails or is conditionally skipped in the testing environment inside Docker.

H4: Route or configuration caching behaves differently inside the CI environment.


Example failing test

class AuthenticationTest extends TestCase
{
    use RefreshDatabase;

    public function test_login_screen_can_be_rendered(): void
    {
        $response = $this->get('/login');

        $response->assertOk();
    }
}

The test uses RefreshDatabase, so the database is recreated for each test.

Locally, this test passes.

In GitLab CI, it consistently returns 404.


Questions

Has anyone encountered a similar issue with Laravel 13?

  • Have there been any recent changes to route registration or the testing bootstrap?
  • What is the best way to debug a situation where nearly every HTTP request returns 404, even though route:list is correct?
  • Is there any known issue with SQLite :memory: in Docker-based CI environments?
  • Would using a file-based SQLite database be more reliable than :memory: during CI testing?
0 likes
4 replies
martinbean's avatar

@leknoppix-708669 All of your errors are 404s pointing to not being able to find routes, yet you show absolutely nothing around how you’re actually registering routes.

So, how are you actually registering routes?

My guess is you’ve got some sort of dynamic hostname, and you‘re taking this into account across environments given your tests pass in one environment but not another.

leknoppix-708669's avatar

Thanks for the suggestion.

I performed another experiment.

I created a brand-new Laravel 13.8 application and ran it through exactly the same GitLab CI pipeline (same Docker image, same PHP version, same PHPUnit configuration).

It passes 100% of the tests, so the CI environment itself appears to be working correctly.

Regarding route registration, I added the following test:

public function test_routes_exist(): void
{
    $routes = app('router')->getRoutes();

    $this->assertGreaterThan(0, count($routes));

    dd($routes);
}

The router contains all the expected routes during PHPUnit execution in CI. The output includes /login, /users, /organisms, etc.

So the routes are definitely being registered correctly.

What I don't understand is why a request such as:

$this->get('/login');

still returns 404 even though /login exists in the router at the exact same point in execution.

Since the routes are present, it seems the issue is no longer route registration itself, but rather why Laravel fails to match them when the test request is executed.

Are there any situations where the testing HTTP kernel could return 404 despite the routes being present? For example, host matching, route groups, URL generation, or something else that behaves differently under PHPUnit?

martinbean's avatar

@leknoppix-708669 How about you just answer my question, instead of providing lots of things I didn’t ask about…?

Show your routes file and how you’re actually registering these routes.

leknoppix-708669's avatar

Hi martinbean, thanks for your help and sorry for the detour.

Here is exactly how routes are registered in my project.

bootstrap/app.php

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias([
            'admin' => EnsureUserIsAdminOrManager::class,
            'admin-only' => EnsureUserIsAdmin::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*'),
        );
    })->create();

routes/web.php

Route::get('/', PageController::class)->name('public.page');
Route::post('/', PageController::class)->name('public.select');
Route::get('/open-tabs', OpenTabsController::class)->name('public.open-tabs');

Route::get('/dashboard', function () {
    return redirect('organisms');
})->middleware(['auth', 'admin'])->name('dashboard');

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});

Route::middleware(['auth', 'admin'])->group(function () {
    Route::get('/statistics', [StatisticController::class, 'index'])->name('statistics.index');
    Route::post('organisms/{organism}/move-up', [OrganismController::class, 'moveUp'])->name('organisms.move-up');
    Route::post('organisms/{organism}/move-down', [OrganismController::class, 'moveDown'])->name('organisms.move-down');
    Route::resource('organisms', OrganismController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy']);
});

Route::middleware(['auth', 'admin-only'])->group(function () {
    Route::resource('users', UserController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy']);
});

require __DIR__.'/auth.php';

routes/auth.php

Route::middleware('guest')->group(function () {
    Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
    Route::post('login', [AuthenticatedSessionController::class, 'store']);
    Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])->name('password.request');
    Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])->name('password.email');
    Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset');
    Route::post('reset-password', [NewPasswordController::class, 'store'])->name('password.store');
});

Route::middleware('auth')->group(function () {
    Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])->name('password.confirm');
    Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
    Route::put('password', [PasswordController::class, 'update'])->name('password.update');
    Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
});

If you spot anything suspicious here, I'd really appreciate the insight. Thanks!

Please or to participate in this conversation.