Abdulkader Safi

A generated /services page shadowed a 301 I shipped three releases ago

A page type can serve a listing at its own prefix without anyone building a page. That route has to sit ahead of the catch-all to exist at all, which means it sits ahead of everything the catch-all was handling. The test suite failed on a redirect written in v0.2.0, and it was right to.

4 min read

Share
A terminal showing a failing test: expected a 301 redirect, received 200.

Register a Service type with a prefix of services and an index view, and /services lists your services. Nobody builds that page. It appears because the type exists.

That is a small feature with a large blast radius, and the blast radius is route ordering.

Why the route has to be early

Atelier's public route is a catch-all:

Route::get('/{locale}/{slug?}', PageController::class)->where('slug', '.*');

It is registered last on purpose, from a booted() callback, so a host application's own routes always win. Laravel matches in registration order, so anything after it is unreachable.

Which means a type's index route must be registered before it. And once it is, /services belongs to the type, not to the catch-all, and the catch-all is what was handling three things at that URL.

What it took over

A real page. The obvious one, handled before writing it. A client who builds a page at services, with copy above the list, expects that page. So the controller looks for a published page at the slug first and hands off to PageController when it finds one. A page you built always beats a page the code generated.

A draft at the same slug. Deliberately not a handoff. If a half-written draft parked at services took the URL, the listing would vanish the moment someone started writing the page that was meant to replace it. So only a published page wins, and the listing keeps serving until the replacement is live.

A redirect. This one I missed, and the test suite caught it in the same run that registered the type in the example app.

Expected response status code [301] but received 200.

RedirectTest has asserted since v0.2.0 that renaming a page 301s its old URL. The fixture renames a page whose slug is services, which is now a route belonging to a page type. So the old URL rendered a service listing with a 200 instead of redirecting, and every inbound link to the renamed page silently stopped pointing anywhere useful.

The fix is one more lookup in the same controller: hand off to PageController when a redirect exists at that slug too, and let the machinery that already handles 301s do its job.

$existing = PageSlug::where('locale', $locale)->where('slug', $prefix)->first();
$redirected = PageRedirect::where('locale', $locale)->where('from_slug', $prefix)->exists();

if ($existing?->page?->isPublished() || $redirected) {
    return ($this->pages)($locale, $prefix);
}

Why the test existed to catch it

Nothing about that test is related to page types. It was written eighteen releases ago for a feature about renaming pages, and it uses services as a fixture slug for no reason other than it being a plausible page name.

The lesson is not "write more tests". It is that a test suite earns most of its value on the change nobody thought was related. I would not have written a page-types test that renamed a page and checked for a 301, because in my head those were separate features. They share a URL space, and the URL space is the thing that does not know about my mental model.

The second one, found the same way

The other failure in that run was the sitemap saying it contained no URLs when it should have been empty. A registered type was emitting its index URL whether or not anything had been published under it, so an empty site advertised /services to crawlers.

An index with nothing on it is not a page worth crawling. Now it is listed only once the type has something published, and a real page at the same slug wins there too, because the page URLs are concatenated first and the list is deduped on the URL.

Where it stands

Shipped in v0.5.0. Type index routes at their prefix in every locale, yielding to a published page or a redirect, listed in the sitemap only when they have something to list.

Two bugs, both found by tests written for other features, both about a URL being claimed by two things at once. That is the recurring shape of bugs in this project, and the reason the catch-all comment in routes/web.php is now four paragraphs long.

Last updated 13 Sep 2026 · filed under Laravel, filamentphp, plugin, tools, web application, seo

Every entry on this project

28 build notes, in order

Including the ones where nothing worked. You are on part 21.

Read the series →

FAQ

Frequently asked questions

What breaks when you register a new route ahead of a catch-all?

Everything the catch-all was doing at that path, which is more than the pages you are thinking about. A catch-all in a content system typically handles the live page, redirects from renamed pages, and the 404 for anything else. A new specific route takes all three, so the new handler has to consciously give back the cases it does not own, usually by delegating to the original controller rather than reimplementing the lookups. List what the catch-all currently answers at that path before adding a route in front of it.

Should generated content win over hand-built content at the same URL, or the other way round?

Hand-built wins, every time. Somebody deliberately created that page, and generated output is a default for the case where nobody has. Making the generated version win means a person can build a page, publish it, and see no change, which is the most confusing possible outcome. Be careful with draft states though: an unpublished draft should not take the URL either, because that would remove working output the moment someone starts editing a replacement.

Why do unrelated tests catch integration bugs that feature-specific tests miss?

Because they were written from a different mental model. You write tests for a feature inside the boundary you imagine that feature has, and integration bugs live exactly where two boundaries you drew separately actually overlap, such as a shared URL space, a shared table or a shared cache key. An old test using a plausible fixture value, such as a page called services, ends up standing in for a real user doing something ordinary. It is a strong argument for running the whole suite on every change rather than only the tests near your diff.

Should an empty listing page be included in a sitemap?

No. A sitemap is a list of URLs you are asking a crawler to spend budget on, and a page listing nothing has no content to index and can look like a soft 404. Emit the URL only once there is something published behind it, and drop it again if the last item is unpublished. The same logic applies to paginated listings and tag or category archives with a single item: the URL existing is not the same as the URL being worth crawling.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from Filament Atelier

All 28 entries →
  • A version number changing from 0.5.0 to 1.0.0 beside a list of deferred items.
    Filament Atelier

    · part 28 of 28

    Tagging 1.0.0 with four features missing, on purpose

    The gate list had thirteen items. Six went in, seven did not, and the tag went out anyway. What the number promises is that the API stops moving, not that the feature list is finished, and conflating those two is how packages sit at 0.x for three years.

  • The same block of JavaScript appearing in three different Blade layout files.
    Filament Atelier

    · part 26 of 28

    A script in three layouts is a contract nobody signed

    The editor's preview needed a few lines of JavaScript in the page it renders. I put them in the shipped layout, then copied them into two more. Anyone writing their own layout had to copy them too, and missing them broke half the editor with no error at all.