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
5 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!

leknoppix-708669's avatar

Update

I finally got my tests passing in CI, but I'm still not 100% sure why the original approach failed. If anyone has a proper explanation, I'd love to hear it.

The workaround: Instead of running apt-get, docker-php-ext-install and composer install as inline CI steps (which consistently produced 404 on route-dependent tests), I baked everything into a Dockerfile.test and ran it via Docker-in-Docker in the CI pipeline.

Same steps, same image (PHP 8.3), but built as a Docker image first, then executed. And now 164/164 tests pass.

FROM php:8.3

RUN 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 \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-install pdo_sqlite mbstring intl bcmath gd zip
RUN docker-php-ext-enable zip

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

WORKDIR /app
COPY . .

RUN composer install --no-interaction

# Avoid ViteManifestNotFoundException (not testing frontend)
RUN mkdir -p public/build && echo '{}' > public/build/manifest.json

RUN cp .env.testing .env \
    && php artisan key:generate --no-interaction \
    && mkdir -p bootstrap/cache storage/logs storage/framework/{cache/data,sessions,views} \
    && chmod -R 777 bootstrap/cache storage database \
    && php artisan optimize:clear \
    && php artisan migrate --no-interaction

CMD ["php", "artisan", "test", "--compact"]

And the CI job:

testsunitaire:
  stage: test
  image: docker:latest
  services:
    - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  script:
    - docker build -f Dockerfile.test -t app-test .
    - docker run --rm app-test

Before: ❌ 45/163 passing After: βœ… 164/164 passing

I'm still looking for a cleaner solution (using the Docker image directly without dind would be ideal). If anyone understands why the same commands in before_script vs in a Dockerfile produce different results, please share!

Please or to participate in this conversation.