Abdulkader Safi

Renaming a page quietly 404s every link to it

Of everything on the SEO list, one item was not a missing feature but active damage: a client renames a page that ranks and every inbound link dies, silently and permanently. That got built first. Then a sitemap I wrote rather than installed, and a robots.txt that a file on disk quietly beats.

5 min read

Share
A sitemap rendered as a readable table of URLs, dates and locale alternates, beside a terminal showing a 301 redirect from an old slug.

v0.1.5 is the SEO release. Sitemap, robots.txt, per-page noindex, and 301s when a slug changes.

I built them in that last order first, because only one item on the list was actively destroying something. The rest were merely absent.

The one that breaks a client site

A client renames a page. Maybe "Services" becomes "What we do". They type it in the slug field, save, and the page moves.

Every inbound link to the old URL now 404s. Every share, every search result, every link somebody else put on their site. Silently and permanently, and the client has no idea it happened because from inside the panel the page looks fine.

Changing a slug now writes a 301 from the old one, and the public route consults redirects before it gives up.

The design decision that turned out to matter: the redirect stores the page, not a target slug.

Rename twice and you get two rows, both resolving to wherever the page lives now. There is no chain to follow and nothing to clean up. If it stored a target slug you would get one → two → three and either a redirect chain or a pruning job. Storing the destination as an identity rather than a location makes the problem not exist.

Two rules around it. A new page claiming a freed slug drops the redirect from it, because whoever claims a slug owns it. And an unpublished target 404s rather than redirecting, because sending somebody to a 404 is worse than the 404 itself.

I wrote the sitemap instead of installing one

There is a good package for this. spatie/laravel-sitemap is well maintained and I have used it before.

Its job is crawling a site to discover URLs. Atelier already knows every URL it has, because they are rows in a table. What was left after removing the discovery problem was about forty lines of XML.

Taking a dependency to do the part you have already done is how a small package becomes a large one. The rule I have been applying to this project is that leaf libraries solving one problem are welcome, and anything that brings a subsystem to solve a problem I do not have is not.

The sitemap lists every published, indexable page in every locale, with xhtml:link alternates and lastmod from the publish time, which the table already stored and nothing read.

Sitemap URLs from outside the CMS

A real client site is rarely only CMS pages. There is usually a blog or a services catalogue with its own model, its own panel tab and its own routes, and Atelier cannot discover any of it.

So a host app hands them over where it registers blocks:

AtelierPlugin::make()
    ->blocks(DefaultBlocks::all())
    ->sitemap([
        fn () => Post::published()->get()->map(fn (Post $post) => [
            'loc' => route('blog.show', $post),
            'lastmod' => $post->updated_at,
        ]),
    ]);

Sources run when the sitemap is requested rather than at boot, so they are free to query. Entries deduplicate on the URL. And a source that throws takes the sitemap down with it, deliberately: a sitemap quietly missing half a site looks fine and stops your blog being indexed, while one that fails is a bug somebody notices.

The package does not filter your entries either. It will not check whether a post is published, because only your model knows that, and pretending otherwise would be guessing on your behalf.

Indexing is decided per locale, not per page

This is the detail I would have got wrong without stopping to think.

A page can be ready in English and half translated in Arabic. So "hide from search engines" is a per locale toggle, and when the Arabic URL is hidden, the hreflang alternate pointing at it drops too.

An alternate pointing at a noindexed URL is the same mistake as listing that URL. You are telling a crawler "here is the Arabic version" while telling it not to index the Arabic version.

One switch, both effects: the tag and the sitemap entry. And the robots meta is only emitted when it says something, because index, follow is what every crawler already assumes and a tag repeating it is noise.

The robots.txt caveat I could not solve

Atelier serves /robots.txt pointing at the sitemap and keeping crawlers out of the panel and the preview route.

Laravel ships a real public/robots.txt, and a file on disk is served by the web server before Laravel runs at all.

So the route does nothing until you delete that file. I confirmed it on a real install rather than assuming: moved the file aside, ours appeared immediately, moved it back, gone again.

A package cannot tell which one you meant. So it is documented as a caveat rather than papered over: delete the file, or copy the Sitemap: line into yours.

Where it stands

v0.1.5. Meta, canonical, hreflang and Open Graph were already there. Now a sitemap covering both locales, a robots.txt, per-page indexing control, and 301s so a rename does not cost a client every link they have.

Still no JSON-LD, which is next. One correction to my own planning docs while I was here: FAQ structured data stopped producing rich results for ordinary sites in August 2023, so it is the cheapest item on that list rather than the highest value one. I had it written down the other way round and would have built it first for the wrong reason.

Last updated 18 Aug 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 9.

Read the series →

FAQ

Frequently asked questions

Should a slug redirect store the new slug or the page it points to?

The page. Storing the target slug means renaming twice produces a chain, where the first old URL points at the second old URL which points at the current one, and you then need either chain following at request time or a job that rewrites earlier rows. Storing the page as an identity means every old URL resolves to wherever that page lives now, however many times it moved, with nothing to follow and nothing to prune. Two rules complete it: a new page claiming a freed slug should drop the redirect from it, and a redirect whose target is unpublished should 404 rather than sending somebody to a page that will 404 anyway.

When should you write a sitemap instead of installing a package?

When the package solves a problem you do not have. Sitemap libraries are mostly crawlers, built to discover URLs by walking a site, which is genuinely hard. A CMS already knows every URL it serves because they are rows in a table, so removing the discovery problem leaves roughly forty lines of XML generation. Taking a dependency to do the part you have already done adds an upgrade surface and a subsystem for no gain. The useful rule is that leaf libraries solving one bounded problem are worth it, and anything bringing an architecture along with it needs to be solving something you actually have.

Why should hiding a page from search engines also drop its hreflang alternate?

Because an alternate is a claim that another URL is the same content in another language, and pointing at a URL you have marked noindex tells a crawler two contradictory things at once. It is the same mistake as listing that URL in the sitemap. This matters most on a partly translated site, where one language is ready and another is not, which is the normal state of a bilingual project for weeks at a time. Deciding indexing per locale rather than per page, and dropping both the sitemap entry and the alternate together, keeps the signals consistent while a translation is still being written.

Why does a package-provided robots.txt route often do nothing?

Because most frameworks ship a real robots.txt file in the public directory, and a web server serves a file on disk before any application code runs. The route is therefore shadowed and never reached, with no error to indicate it. A package cannot detect which one the site intended, so the only honest options are to document it clearly or to publish a file that overwrites the existing one, which is worse because it silently discards rules somebody may have written. Documenting it means telling people to delete the static file or copy the sitemap line into it, and verifying the behaviour on a real install rather than assuming.

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.