Abdulkader Safi

Installing my own plugin on my own site broke the homepage first

Atelier alone, before converting a single page, silently took over `/` and `/sitemap.xml` on a site that already had both. `route('home')` threw `RouteNotFoundException` even though the route was still sitting in `web.php`. The docs said the opposite of what actually happens.

3 min read

Share
A route list showing the homepage route registered and named, resolving to the wrong controller.

The first commit that installs Atelier on this site doesn't touch a single page. It configures one locale, registers a layout, wraps this site's existing components as blocks so the visual language stays exactly what it was. Nothing about the live site was supposed to change yet.

The homepage broke anyway.

route('home') doesn't exist, except it does

routes/web.php still had Route::get('/', [HomeController::class, 'index'])->name('home'). I hadn't removed it, hadn't touched it. Calling route('home') in a test threw RouteNotFoundException. php artisan route:list still showed the route, named, registered, present.

Laravel's RouteCollection keys routes by method and URI. For two routes registered at the exact same literal path, the last one registered wins outright, replacing the first entirely. Not "first match wins," which is what I'd assumed, and what I'd written in this package's own install docs. That rule only holds for genuinely different patterns, /projects against /{locale}/{slug?}, where Laravel matches in registration order and a specific route can win regardless of position. For an identical literal string, there's no matching contest. The second registration overwrites the first.

Atelier registers its own / and /sitemap.xml from a booted() callback, timed deliberately to run after routes/web.php loads, so a host app's own catch-all doesn't need to guess at load order. That timing is exactly why it won: this site's / was registered when web.php loaded, Atelier's / was registered after, in the very next boot phase, and the later one took the slot.

The fix, and the test that has to exist now

Re-registered / and /sitemap.xml from an even later booted() callback, timed to run after Atelier's own. Same trick Atelier itself uses, aimed back at it.

app()->booted(function () {
    Route::get('/', [Pages\HomeController::class, 'index'])->name('home');

    Route::get('/sitemap.xml', function () {
        return response()->file(public_path('sitemap.xml'));
    });
});

Added a regression test that asserts the route named home actually resolves to /, and the route named sitemap.xml doesn't resolve to atelier.sitemap. Cheap insurance against a future version of this package changing its boot timing again.

The collateral damage

Fixing the collision broke a second, unrelated test. A hardening test dynamically registered a GET route to probe how the app handles a malformed request. That probe was now shadowed by Atelier's own GET-only /{locale}/{slug?} catch-all, which had just become active for real. Switched the probe to POST, since Atelier registers no POST routes, and it stopped competing with anything.

Where it stands

Zero pages converted, one route collision found and fixed, one regression test added, one unrelated test quietly broken and repaired. This is what dogfooding actually catches: not whether the block system renders correctly, but whether installing this thing on a site that already has routes of its own does what the docs promise. It didn't. The docs are getting fixed to match.

Last updated 28 Aug 2026 · filed under Laravel, filamentphp, plugin, tools, web application

Every entry on this project

28 build notes, in order

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

Read the series →

FAQ

Frequently asked questions

Why does a package's booted()-registered route win over an app's own identically named route?

Laravel's RouteCollection keys routes internally by HTTP method plus URI. For two routes registered at the exact same literal path, the collection overwrites the earlier entry with the later one; there's no first-match contest for identical strings the way there is for competing patterns. A package that registers its catch-all from a booted() callback, so it loads after the host app's routes/web.php, will replace an app's own route at that exact path rather than losing to it, which is the opposite of what most install guides, including this one until now, imply.

Does this collision rule apply to every route, or only exact matches?

Only exact literal-path matches. Two different patterns, like /projects and /{locale}/{slug?}, don't collide this way; Laravel iterates candidate routes in registration order and matches the first pattern that fits the incoming URI, so a more specific route registered earlier can still win against a broader one registered later. The overwrite behaviour is specific to two routes claiming the identical string, which is rarer, and easier to miss until it happens to a path as central as a homepage.

How do you defend against a route collision like this happening again?

Re-register the contested routes from a booted() callback timed later than the package's own, so the host app's version loads last and wins the overwrite. Then write a test that asserts the route by name resolves to the expected URI and controller, not just that a request to the path returns 200, since a 200 from the wrong controller would pass a status-only test while still being broken.

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.