Unmatched and unauthenticated web routes return a JSON 404 instead of redirecting to / #135

Open
opened 2026-08-12 21:20:47 +02:00 by myrmidex · 1 comment
Owner

Problem

Loading http://localhost:8000/dashboard in a browser after the containers come back up shows a "cannot find the page" result instead of a usable page. Navigating to the app root produces the login screen, after which /dashboard works again.

routes/web.php:59 defines a catch-all fallback that returns JSON for any unmatched web route:

Route::fallback(function () {
    return response()->json([
        'message' => 'This is the FFR API backend. Use /api/v1/* endpoints or check the React frontend.',
        'api_base' => '/api/v1',
    ], 404);
});

In a browser this renders as a bare JSON blob rather than a page, which is what "cannot find the page" looks like from the user's side.

Desired behaviour

  • A web request to a route that does not exist (404) redirects to /.
  • A web page request from an unauthenticated user redirects to / rather than dead-ending.
  • / already redirects to dashboard (routes/web.php:15), and the auth middleware sends guests to the login screen — so the redirect lands the user somewhere sensible in both cases.

Scope

  • Web routes only. The /api/v1/* endpoints must keep returning JSON for 404s and auth failures in all cases — API clients depend on the JSON contract and must not be redirected.
  • The existing JSON fallback body should stay available to API traffic; only the web-route path changes.

Notes

  • Auth redirection itself is already wired: protected routes sit behind the auth middleware (routes/web.php:20-37), which resolves to the login route from routes/auth.php. The gap is the fallback behaviour and the browser-facing 404, not the auth guard.
  • No custom redirectGuestsTo / redirectTo configuration exists in bootstrap/app.php or app/ — the framework defaults are in play.

Acceptance criteria

  • Requesting a non-existent web URL as a guest redirects to /.
  • Requesting a non-existent web URL while authenticated redirects to /.
  • Requesting a protected web page as a guest redirects to / (and onward to login).
  • GET /api/v1/<nonexistent> still returns a JSON 404.
  • An unauthenticated /api/v1/* request still returns a JSON error response, not a redirect.
## Problem Loading `http://localhost:8000/dashboard` in a browser after the containers come back up shows a "cannot find the page" result instead of a usable page. Navigating to the app root produces the login screen, after which `/dashboard` works again. `routes/web.php:59` defines a catch-all fallback that returns JSON for **any** unmatched web route: ```php Route::fallback(function () { return response()->json([ 'message' => 'This is the FFR API backend. Use /api/v1/* endpoints or check the React frontend.', 'api_base' => '/api/v1', ], 404); }); ``` In a browser this renders as a bare JSON blob rather than a page, which is what "cannot find the page" looks like from the user's side. ## Desired behaviour - A **web** request to a route that does not exist (404) redirects to `/`. - A **web** page request from an unauthenticated user redirects to `/` rather than dead-ending. - `/` already redirects to `dashboard` (`routes/web.php:15`), and the auth middleware sends guests to the login screen — so the redirect lands the user somewhere sensible in both cases. ## Scope - **Web routes only.** The `/api/v1/*` endpoints must keep returning JSON for 404s and auth failures in all cases — API clients depend on the JSON contract and must not be redirected. - The existing JSON fallback body should stay available to API traffic; only the web-route path changes. ## Notes - Auth redirection itself is already wired: protected routes sit behind the `auth` middleware (`routes/web.php:20-37`), which resolves to the `login` route from `routes/auth.php`. The gap is the fallback behaviour and the browser-facing 404, not the auth guard. - No custom `redirectGuestsTo` / `redirectTo` configuration exists in `bootstrap/app.php` or `app/` — the framework defaults are in play. ## Acceptance criteria - [ ] Requesting a non-existent web URL as a guest redirects to `/`. - [ ] Requesting a non-existent web URL while authenticated redirects to `/`. - [ ] Requesting a protected web page as a guest redirects to `/` (and onward to login). - [ ] `GET /api/v1/<nonexistent>` still returns a JSON 404. - [ ] An unauthenticated `/api/v1/*` request still returns a JSON error response, not a redirect.
myrmidex added this to the v2.0.0 milestone 2026-08-12 21:20:47 +02:00
myrmidex added the
bug
label 2026-08-12 21:20:47 +02:00
myrmidex modified the milestone from v2.0.0 to v1.4.0 2026-08-12 21:23:49 +02:00
Author
Owner

Attempted 2026-08-13, reverted and moved to backlog. No code landed. Recording the investigation so the next attempt does not start cold.

The fix is not as simple as changing the fallback

1. /api/v1/* has no fallback of its own — unmatched API routes hit the web fallback.

Verified by matching against the router: /api/v1/nonexistent resolves to the closure in routes/web.php with web middleware, because routes/api.php defines no Route::fallback(). Changing the web fallback to redirect('/') on its own therefore breaks acceptance criterion 4GET /api/v1/<nonexistent> would start redirecting instead of returning JSON. The API needs its own fallback first.

2. expectsJson() is not a safe discriminator.

An /api/v1/* request without an Accept: application/json header returns expectsJson() === false. Any fix that branches on content negotiation will redirect API clients that omit the header, breaking criterion 5. The reliable discriminator is the path prefix, not the Accept header. Worth a test for the header-less case specifically — it is the real regression risk and is not currently covered.

3. Acceptance criterion 3 is unimplementable as written.

Requesting a protected web page as a guest redirects to / (and onward to login).

Setting redirectGuestsTo('/') produces an infinite redirect loop:

guest GET /dashboard
  -> auth middleware      -> redirect /
  -> / (web.php:15)       -> redirect()->route('dashboard')
  -> auth middleware      -> redirect /
  -> ... forever

Because / immediately redirects to dashboard, which is itself behind auth, sending guests to / cannot terminate. Laravel's default (/login) terminates precisely because /login sits behind guest middleware instead.

This criterion needs a decision before the ticket is picked up again. Either:

  • drop it and accept Laravel's default (guest → /login), which reaches the same end state in fewer hops; or
  • make / behave differently for guests (serve something, or redirect to login when not authenticated) so it is a valid landing point.

The other four criteria are achievable.

Blocker discovered

tests/Feature/ApiAccessTest.php hangs indefinitely — including test_health_endpoint_is_accessible, which merely calls GET /health. Reproduced on a clean checkout with no local changes, after a database container restart, while FeedsTest (21 tests) and ChannelsTest (26 tests) both pass in ~3s against the same database. So it is specific to this test file, not the DB and not the changes attempted here.

That needs diagnosing before #135 can be verified at all, since ApiAccessTest is where its coverage belongs. It may deserve its own ticket.

Suggested approach for next time

  1. Diagnose the ApiAccessTest hang first — it blocks verification of anything here
  2. Add Route::fallback() inside the v1 prefix group in routes/api.php, returning the existing JSON body
  3. Only then change the web fallback to redirect('/')
  4. Resolve criterion 3 with the user before touching redirectGuestsTo
  5. Cover the header-less /api/v1/* case explicitly

Note that ApiAccessTest::test_fallback_route_returns_api_message currently pins the behaviour this ticket calls a bug, and will need rewriting rather than extending.

Attempted 2026-08-13, **reverted and moved to backlog**. No code landed. Recording the investigation so the next attempt does not start cold. ## The fix is not as simple as changing the fallback **1. `/api/v1/*` has no fallback of its own — unmatched API routes hit the *web* fallback.** Verified by matching against the router: `/api/v1/nonexistent` resolves to the closure in `routes/web.php` with `web` middleware, because `routes/api.php` defines no `Route::fallback()`. Changing the web fallback to `redirect('/')` on its own therefore **breaks acceptance criterion 4** — `GET /api/v1/<nonexistent>` would start redirecting instead of returning JSON. The API needs its own fallback first. **2. `expectsJson()` is not a safe discriminator.** An `/api/v1/*` request **without** an `Accept: application/json` header returns `expectsJson() === false`. Any fix that branches on content negotiation will redirect API clients that omit the header, breaking criterion 5. The reliable discriminator is the path prefix, not the Accept header. Worth a test for the header-less case specifically — it is the real regression risk and is not currently covered. **3. Acceptance criterion 3 is unimplementable as written.** > Requesting a protected web page as a guest redirects to `/` (and onward to login). Setting `redirectGuestsTo('/')` produces an infinite redirect loop: ``` guest GET /dashboard -> auth middleware -> redirect / -> / (web.php:15) -> redirect()->route('dashboard') -> auth middleware -> redirect / -> ... forever ``` Because `/` immediately redirects to `dashboard`, which is itself behind `auth`, sending guests to `/` cannot terminate. Laravel's default (`/login`) terminates precisely because `/login` sits behind `guest` middleware instead. **This criterion needs a decision before the ticket is picked up again.** Either: - drop it and accept Laravel's default (guest → `/login`), which reaches the same end state in fewer hops; or - make `/` behave differently for guests (serve something, or redirect to `login` when not authenticated) so it is a valid landing point. The other four criteria are achievable. ## Blocker discovered `tests/Feature/ApiAccessTest.php` **hangs indefinitely** — including `test_health_endpoint_is_accessible`, which merely calls `GET /health`. Reproduced on a clean checkout with no local changes, after a database container restart, while `FeedsTest` (21 tests) and `ChannelsTest` (26 tests) both pass in ~3s against the same database. So it is specific to this test file, not the DB and not the changes attempted here. That needs diagnosing before #135 can be verified at all, since `ApiAccessTest` is where its coverage belongs. It may deserve its own ticket. ## Suggested approach for next time 1. Diagnose the `ApiAccessTest` hang first — it blocks verification of anything here 2. Add `Route::fallback()` inside the `v1` prefix group in `routes/api.php`, returning the existing JSON body 3. Only then change the web fallback to `redirect('/')` 4. Resolve criterion 3 with the user before touching `redirectGuestsTo` 5. Cover the header-less `/api/v1/*` case explicitly Note that `ApiAccessTest::test_fallback_route_returns_api_message` currently pins the behaviour this ticket calls a bug, and will need rewriting rather than extending.
myrmidex removed this from the v1.4.0 milestone 2026-08-13 21:21:26 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lvl0/fedi-feed-router#135
No description provided.