Every route in the application, transcribed from php artisan route:list.
135 routes are registered — 25 of them package plumbing summarised in §13,
the rest listed individually below. The package side carries a build hash that
changes between releases, and _dusk/* appears only when Dusk is installed and
the app is local, so a total taken in production will be lower.
Where a guard is stated, it is the enforced guard — the route middleware, not the intent. Where the two differ, the difference is called out.
1. Route files
| File | Contains |
|---|---|
routes/web.php |
Public pages, the role dashboards, the admin workspace, the platform modules, the editor AI endpoints, the composer's media door, backward-compatible redirects |
routes/settings.php |
Account settings, passkey endpoints |
routes/console.php |
No HTTP routes — two scheduled commands: feed:publish-scheduled every minute, feed:prune-media daily |
| Framework / Fortify / Livewire | Auth, 2FA, passkeys, Livewire transport, Flux assets |
There is no routes/api.php — see API.md.
2. Public routes
No authentication. These are the pages a visitor or crawler sees.
| Method | URI | Name | Component / Controller |
|---|---|---|---|
GET |
/ |
home |
pages::home |
GET |
/about |
about |
pages::about |
GET |
/blog |
blog.index |
pages::blog.index |
GET |
/blog/{post:slug} |
blog.show |
pages::blog.show |
GET |
/work/{project}/{doc?} |
work.show |
pages::work.show — the optional segment is matched against the project's own docs index |
GET |
/feed |
feed.index |
pages::feed |
GET |
/feed/{slug} |
feed.show |
pages::feed.show — a string parameter, not a bound model |
GET |
/feed/attachments/{attachment} |
feed.attachments.show |
FeedAttachmentController — signed, temporary |
GET |
/communities |
communities.index |
pages::communities.index |
GET |
/errors/{code} |
errors.show |
ErrorPageController — whereIn('code', 400|401|403|404|500|503); answers with the real status |
GET |
/feed.xml |
feed |
FeedController |
GET |
/sitemap.xml |
sitemap |
SitemapController |
GET |
/robots.txt |
RobotsController (no name) |
RobotsController |
Middleware: web only.
The error pages deserve a note: /errors/{code} is the preview surface for the
same views the exception handler renders on a real abort() — the framework's
errors.{status} discovery picks up resources/views/errors/{code}.blade.php,
which all delegate to the reusable x-app.errors.page component (copy sourced
from App\Support\HttpErrorCatalog). Uncaught (non-HTTP) exceptions are mapped
to the styled 500 by a renderable in bootstrap/app.php, but only outside debug
mode, where the trace matters more than the friendly page.
/feed wears the default public shell (layouts::guest.home): the shared
header, footer and auth dialogs are the site's own, and the page no longer opts
out with #[Layout]. Every feed filter — topic, sort, type, tag, search — is
URL-bound state on that component, so a filtered view is a shareable link and
survives a reload.
Route-model binding keys matter here. feed.show resolves its {slug} by
hand inside mount(), so the component can run the visibility rule on the row it
found rather than trusting the binding: visible() first (the corpus-wide floor)
and then isVisibleTo($reader) (the reader-shaped one), each answering 404. A
post shared with its author alone and a post that does not exist give the same
answer, which is the point. blog.show additionally:
- publishes
draftposts only to their author, - returns
404— not403— for a draft belonging to someone else, so the route does not confirm that an unpublished post exists.
Livewire's page controller answers most of this file's routes, and
php artisan route:list --except-vendor therefore hides them. The plain command
shows 135 routes; the --except-vendor form shows 22, because every
Route::livewire(...) entry resolves to Livewire\Mechanisms › LivewirePageController and is filtered out with the vendor code. Anyone auditing
the route table from that 22 is looking at the exceptions and calling them the rule.
Those exceptions are the application's six real controllers:
| Controller | Routes | Returns |
|---|---|---|
FeedController |
feed.xml |
RSS XML |
SitemapController |
sitemap.xml |
sitemap XML |
RobotsController |
robots.txt |
plain text |
FeedAttachmentController::show |
feed/attachments/{attachment} |
bytes, signed (below) |
ErrorPageController |
errors/{code} |
the catalogued status pages |
Editor\EditorAiController |
six editor/ai/* routes |
JSON, called by editor-ai.js |
The first three are plain controllers because they return XML and plain text, not HTML. The last three exist because a Livewire page is the wrong shape for them: an attachment is a streamed file with a signature, an error page must render without a component context to render inside, and the editor's AI calls are fetch requests from Alpine that must answer with JSON and answer fast.
The composer has no route of its own. Writing a post is not a page visit: the
entry point is the x-app.ui.modal.trigger in components/feed/composer.blade.php
and the form is pages::feed.composer-modal, so a publish is a Livewire update
request, not a POST /feed. That is a deliberate omission rather than a gap —
there is no form action for an attacker to find, no CSRF token to get wrong, and
nothing to authorise before the component runs. What replaces the route is a
policy (App\Policies\FeedPostPolicy), a per-account rate limiter (feed-publish,
5 posts / 60 s, lifted for admins) and a 10-second duplicate guard, all of which
fire inside App\Actions\Feed\ComposeFeedPost however the call arrived.
feed.attachments.show is the exception, and the only route in the application
whose only purpose is to move bytes. The composer's images live on the
feed_media disk, which is configured 'serve' => false, so no stock storage URL
reads them and this route is the single door. It is declared before
feed/{slug} so the two-segment detail route can never claim a three-segment
path. Two independent locks stand in front of it: URL::hasValidSignature(),
which answers 400 — a minted link is valid for ten minutes, and a forged
attachment id and an expired stamp both fail at the same place — then
Gate::allows('viewAttachment', …), which re-runs the post's visibility rule
against this request and answers 404. A forwarded, expired, or since-restricted
signed URL therefore fails closed, and it fails as not-found rather than forbidden
so a private post's existence is not confirmed.
3. Authentication routes
Registered by Fortify, not by this application. Views are supplied through
FortifyServiceProvider::configureViews() and live in
resources/views/pages/auth/.
| Method | URI | Name | View |
|---|---|---|---|
GET |
/login |
login |
pages::auth.login |
POST |
/login |
login.store |
— |
POST |
/logout |
logout |
— |
GET |
/register |
register |
pages::auth.register |
POST |
/register |
register.store |
— |
GET |
/forgot-password |
password.request |
pages::auth.forgot-password |
POST |
/forgot-password |
password.email |
— |
GET |
/reset-password/{token} |
password.reset |
pages::auth.reset-password |
POST |
/reset-password |
password.update |
— |
Where a successful login lands
This is the single most important behaviour in this file, and it was wrong in the inherited codebase.
config/fortify.php defines only 'home' => '/dashboard' and declares no
per-route redirects map, so Fortify delegates to the response classes in
app/Http/Responses/. Each one resolves its destination through
AccountRole::dashboardRoute():
| Role | Route | URI |
|---|---|---|
user |
dashboard.index |
/dashboard |
seller |
seller.dashboard |
/seller/dashboard |
admin |
admin.dashboard |
/admin/dashboard |
Each role owns a top-level URL space: the role is the first path segment, so
the address bar names the surface. No role dashboard is nested inside another —
the previous /{current_team}/dashboard scoping is gone along with the team
feature, and no registered route carries a current_team or team parameter.
The response classes resolve the destination through
AccountRole::dashboardRoute() and guard with app('router')->has($routeName),
falling back to /dashboard, so a role can be declared in the enum before its
dashboard route exists without producing a 500. That fallback is why
'home' => '/dashboard' still matters: it is what a role with no registered
dashboard route receives.
Email verification
| Method | URI | Name |
|---|---|---|
GET |
/email/verify |
verification.notice |
GET |
/email/verify/{id}/{hash} |
verification.verify |
POST |
/email/verification-notification |
verification.send |
{id} and {hash} are both required, and hash is an HMAC of the user id
derived from APP_KEY. A verification link cannot be forged from an id alone.
Two-factor challenge
| Method | URI | Name |
|---|---|---|
GET |
/two-factor-challenge |
two-factor.login |
POST |
/two-factor-challenge |
two-factor.login.store |
Both are reachable only mid-login, when the session has passed the password step but not the second factor. A guest requesting either is redirected.
4. Role dashboards
Three separate, role-owned URL spaces. The role is the first path segment, so no role's home is nested inside another's. Each is stated with its exact enforced middleware.
| Method | URI | Name | Middleware |
|---|---|---|---|
GET |
/dashboard |
dashboard.index |
auth, verified |
GET |
/seller/dashboard |
seller.dashboard |
auth, verified, permission:product:create |
GET |
/admin/dashboard |
admin.dashboard |
auth, verified, permission:admin:access |
GET |
/admin/posts |
admin.posts.index |
auth, verified, permission:admin:access |
GET |
/admin/posts/create |
admin.posts.create |
auth, verified, permission:admin:access |
GET |
/admin/posts/{post:id}/edit |
admin.posts.edit |
auth, verified, permission:admin:access + policy |
GET |
/admin/comments |
admin.comments |
auth, verified, permission:admin:access |
GET |
/admin/taxonomy |
admin.taxonomy |
auth, verified, permission:admin:access |
GET |
/admin/feed |
admin.feed |
auth, verified, permission:admin:access, permission:comment:moderate |
GET |
/admin/users |
admin.users |
auth, verified, permission:admin:access, permission:user:manage |
GET |
/admin/marketplace |
admin.marketplace |
auth, verified, permission:admin:access, permission:product:review |
GET |
/admin/about |
admin.about |
auth, verified, permission:admin:access, permission:about:manage |
GET |
/admin/ai-hub |
admin.ai-hub |
auth, verified, permission:admin:access, permission:ai-hub:manage |
GET |
/admin/dev-lab |
admin.dev-lab |
auth, verified, permission:admin:access |
GET |
/admin/ideas |
admin.ideas |
auth, verified, permission:admin:access |
admin.dev-lab and admin.ideas are platform-wide consoles over the two
modules' rows: every snippet and execution log entry for the lab, every idea and
plan for Ideas. They carry only the group's admin:access and no narrower
permission, because there is nothing to divide: the console reads across accounts
and, for snippets, removes them. Unlike the other module surfaces they are
offered as destinations — both appear in the admin rail, because unlike feed
moderation they now have work in them.
What a console may not do is the interesting half of the design. The Ideas
console lists and filters; it never edits. An idea and a plan are somebody's
thinking, and rewriting it is not what an operator key buys. The lab console
lists snippets and runs and may delete a snippet, but holds no editor at all:
the sandbox is the only place code is written, and it belongs to the account
writing it. admin.about and admin.ai-hub follow the marketplace pattern of
declaring a second, narrower permission in addition to the panel key; the two
module consoles deliberately do not, because ideas:use and dev-lab:use
describe authoring, not oversight, and reusing them as the moderation lock would
let every account that can write into a module see everyone else's rows.
/dashboard is the user role's home and a read-only overview of that
account's own content. The authoring screens — posts, the editor, the comment
queue, taxonomy — live under /admin and are gated by permission:admin:access,
so a plain user or a seller is refused them and sees no link to them anywhere.
Only the homes are distinct: /dashboard stays reachable by every
authenticated, verified account even though it is the user role's landing page.
The seller and admin homes are guarded by a permission, not by a role name —
a role check would drift from the permission matrix, a permission check cannot.
What limits the authoring screens is two different things, in that order.
admin:access decides who may open the page at all. Inside it, every query is
scoped to the signed-in account (authoredBy(auth()->user())), so an admin
editing from /admin still sees only their own posts. Note that a Livewire
mutation is a POST /livewire/update, which never passes through the route
group — each of those four components re-checks admin:access in its own
boot(), and that re-check is the actual defence.
The whole dashboard.* group is declared under middleware(['auth', 'verified']), prefix('dashboard'), name('dashboard.'), and holds one route
today. The seller group uses prefix('seller'), name('seller.') and the admin
group prefix('admin'), name('admin.') — each declares its middleware, URL
prefix, and name prefix once, so a route line states only what is unique about
that route. A group with a single page is still a group, because the prefix and
the permission belong to the space, not to the page in it.
Two design decisions worth stating
/dashboard is deliberately unguarded by permission. It is the landing page
for every role, and pages::dashboard.index's mount() forwards a seller or
admin to the dashboard their role owns via redirectRoute($routeName, navigate: true). Guarding it with a permission would make it unreachable for the roles
that must land there first in order to be forwarded — the redirect is a real
HTTP redirect so the URL always names what is on screen.
admin/posts/{post:id}/edit binds by id, not slug. The editor changes the
post you own; a slug is a publication concern, not an authoring one — and it is
something the editor itself rewrites. The public route binds by slug. The
different key is intentional and the routes are not interchangeable.
admin.posts.create and admin.posts.edit render the same
component (pages::admin.posts.edit). One editor serves both create and
update; the presence of a bound Post is what distinguishes them. This is why
the route guard stops at admin:access — the authorisation question ("may this
account edit this post") cannot be answered until the model is bound, so it is
answered inside mount(), which 404s a post belonging to someone else rather
than admitting it exists.
5. Platform module routes
Four product modules, each with its own top-level URL space. Three are built —
Ideas, the Developer Lab and the Marketplace — and the AI Hub is built as a
reading surface whose two curated shelves still render placeholders (via
resources/views/components/app/ui/module-placeholder.blade.php).
All four follow the contract the marketplace stated first: reading is public,
writing is gated. A guest may browse published ideas, published snippets and
product listings without an account, because reading is not entering a workspace.
What an account may do inside a module is decided by ideas:use,
dev-lab:use and product:create, and those guard the workspace routes below.
Neither module permission gates a URL a reader needs, and no permission case was
added to build either module — see PERMISSIONS.md.
Public surfaces
| Method | URI | Name | Middleware |
|---|---|---|---|
GET |
/dev-lab |
dev-lab.index |
— |
GET |
/dev-lab/gallery |
dev-lab.gallery |
— |
GET |
/dev-lab/snippets/{snippet:slug} |
dev-lab.snippets.show |
— |
GET |
/ai-hub |
ai-hub.index |
— |
GET |
/ai-hub/articles |
ai-hub.articles |
— |
GET |
/ai-hub/articles/{article:slug} |
ai-hub.article |
— |
GET |
/ai-hub/topics |
ai-hub.topics |
— |
GET |
/ai-hub/topics/{topic:slug} |
ai-hub.topic |
— |
GET |
/ai-hub/paths |
ai-hub.paths |
— |
GET |
/ai-hub/paths/{path:slug} |
ai-hub.path |
— |
GET |
/ai-hub/resources |
ai-hub.resources |
— |
GET |
/marketplace |
marketplace.index |
— |
GET |
/marketplace/categories/{category:slug} |
marketplace.category |
— |
GET |
/marketplace/{product:slug} |
marketplace.show |
— |
GET |
/ideas |
ideas.index |
— |
GET |
/ideas/{idea:slug} |
ideas.show |
— |
GET |
/topics |
topics.index |
— |
GET |
/topics/{topic:slug} |
topics.show |
— |
The marketplace category route renders the same component as the index with the
filter pre-seeded, so there is one definition of "the catalogue" rather than two.
marketplace.show resolves {product:slug} by slug and returns 404 for any
listing that is not Published — unless the viewer is its creator or holds
product:review, which is the same rule the component applies in
MarketplaceProduct::isVisibleTo().
ideas.show and dev-lab.snippets.show apply the identical rule through their
own models: Idea::isVisibleTo() and LabSnippet::isVisibleTo() admit the
author and an admin:access holder always, and a guest or a stranger only when
the row is both Public and carries a published_at. Publication is the
conjunction, never the flag alone, and it is why an idea somebody merely
flipped to public without a timestamp has no public URL. The pair is written in
one place each — SyncIdeaVisibility and SaveLabSnippet — so a row cannot
become readable through a code path nobody audited.
/topics is the one public surface that belongs to no module. It lists the
platform-owned subjects both modules file into, and topics.show opens one of
them with its published ideas and published snippets side by side. Its pair
sits at the same depth as the two module detail routes but under a different
first segment, so nothing about the ordering rules below applies to it and no
word needed reserving: an idea slugged topics lives at /ideas/topics, one
segment deeper, and neither route can swallow the other. topics.show refuses
with a 404 through PublicTaxonomy::isBrowseable() rather than rendering an
empty shelf, which covers two states at once — a subject switched off, and a
subject that exists only because it was seeded and has never carried
a published row.
The two landings, ideas.index and dev-lab.gallery, now take ?topic=,
?author= and ?sort= beside the ?q= and ?page= they already had, and each
keeps its own pre-existing facet — ?tab= on the ideas index, ?lang= on the
gallery. They are #[Url]-bound properties on the components with their default
hidden, so an unfiltered page keeps a clean canonical URL. A value that fails the
shared gate (an inactive subject, a handle with nothing published behind it, an
unknown sort word) is treated as no filter rather than as an error: these are
readers' URLs, typed and pasted, and a page that 404s on a stale filter is a page
that teaches somebody nothing. The parameters are documented in
API.md.
The URL space is claimed on purpose: /ideas, /dev-lab, /ai-hub and
/marketplace are top-level names that must not be taken by anything else.
Where a surface is gated, EnsureAccountPermission redirects a guest to the
home page with the sign-in dialog open rather than refusing them, because they
may hold the permission after authenticating.
Ideas workspace
| Method | URI | Name | Middleware |
|---|---|---|---|
GET |
/ideas/dashboard |
ideas.dashboard |
auth, verified, permission:ideas:use |
GET |
/ideas/create |
ideas.create |
same |
GET |
/ideas/categories |
ideas.categories |
same |
GET |
/ideas/plans |
ideas.plans |
same |
GET |
/ideas/plans/create |
ideas.plan.create |
same |
GET |
/ideas/plans/{plan:slug} |
ideas.plan |
same |
GET |
/ideas/plans/{plan:slug}/edit |
ideas.plan.edit |
same |
GET |
/ideas/{idea:slug}/edit |
ideas.edit |
same |
GET |
/admin/ideas |
admin.ideas |
auth, verified, permission:admin:access |
Developer Lab workspace
| Method | URI | Name | Middleware |
|---|---|---|---|
GET |
/dev-lab/dashboard |
dev-lab.dashboard |
auth, verified, permission:dev-lab:use |
GET |
/dev-lab/sandbox |
dev-lab.sandbox |
same |
GET |
/dev-lab/snippets |
dev-lab.snippets |
same |
GET |
/dev-lab/snippets/create |
dev-lab.snippets.create |
same |
GET |
/dev-lab/snippets/{snippet:slug}/edit |
dev-lab.snippets.edit |
same |
GET |
/dev-lab/history |
dev-lab.history |
same |
GET |
/admin/dev-lab |
admin.dev-lab |
auth, verified, permission:admin:access |
Registration order is the property that keeps the two audiences apart, and it
is not incidental. In each module the workspace group is declared before the
public pair, and inside the group the literals come before the parametric routes:
/ideas/plans before /ideas/{idea:slug}, snippets/create and
snippets/{snippet:slug}/edit before /dev-lab/snippets/{snippet:slug}. Laravel
matches in registration order, so an idea whose slug happens to be plans can
never swallow the plans screen — and App\Rules\IdeaSlug, PlanSlug and
SnippetSlug refuse that name from the other direction, at the write, so a row
is never born claiming a URL that already opens a screen. Both defences are
required: the ordering protects the screens from the data, the rule protects the
data from itself.
ideas.create and ideas.edit render one component, as do
ideas.plan.create / ideas.plan.edit and dev-lab.snippets.create /
dev-lab.snippets.edit — the presence of a bound model is what distinguishes
creating from updating, exactly as in the post editor. Authoring routes bind by
slug too, which is a deliberate difference from /admin/posts/{post:id}/edit: a
module row's slug is cheap to type, stable once reserved, and the components 404
rather than 403 a row belonging to somebody else. LabWorkspace is the exception
that keeps an id key: a workspace is never public, so it needs no canonical
name, and the sandbox takes no route parameter at all — it loads the account's
own current workspace.
The hub's two curated shelves share the /ai-hub URL space without sharing its
visibility — they are staff pages:
| Method | URI | Name | Middleware |
|---|---|---|---|
GET |
/ai-hub/dev-lab |
ai-hub.dev-lab.index |
auth, verified, permission:ai-hub:manage |
GET |
/ai-hub/ideas |
ai-hub.ideas.index |
auth, verified, permission:ai-hub:manage |
They are different pages from the public /dev-lab and /ideas landings, which
is why the names stay namespaced under ai-hub. rather than reusing
dev-lab.index / ideas.index. Building the Ideas workspace and the sandbox did
not build these two: a shelf is hub-side curation over module rows, which is a
different object from the module's own console at /admin/ideas and
/admin/dev-lab. They stay placeholders until that curation model exists, and
RoutingStructureTest asserts that a placeholder still reads as one.
6. Editor AI routes
Six JSON endpoints backing the rich text editor's AI features. They live under
/editor/ai and are grouped by a single auth, verified,
permission:post:create middleware stack — anyone who can write a post can use
the editor's AI, and nobody else can.
| Method | URI | Name | Throttle |
|---|---|---|---|
GET |
/editor/ai/capabilities |
editor.ai.capabilities |
— |
POST |
/editor/ai/preview |
editor.ai.preview |
120/min |
POST |
/editor/ai/correct |
editor.ai.correct |
20/min |
POST |
/editor/ai/generate |
editor.ai.generate |
10/min |
POST |
/editor/ai/image-prompt |
editor.ai.image-prompt |
15/min |
POST |
/editor/ai/image |
editor.ai.image |
5/min |
Why JSON and not Livewire. These actions fire from inside a text editor that
owns its own selection and cursor state. A Livewire round trip re-renders the
DOM and loses the textarea's caret position — the single most irritating thing a
writing tool can do. The editor keeps its own state client-side and calls these
endpoints with fetch(), syncing the finished value back through $wire.set().
Throttles are per-minute and deliberately tight on the expensive endpoints: image generation in particular is billed per call, and 5/min is generous for authoring while useless for draining a budget.
Error shape. Every failure returns the same body, so the front end has one code path:
{ "message": "AI features are not configured…", "reason": "not_configured" }
reason maps to a status: not_configured → 503, provider_failed → 502,
malformed_response → 502, storage_failed → 500. See
app/Services/EditorAiException.php.
Preview works with no provider configured. It touches no model — it routes
Markdown through App\Support\MarkdownRenderer, the same renderer that serves
the published post. A client-side renderer would be a second sanitiser
implementation and the two would drift, at which point the preview stops
predicting the published output.
7. Backward-compatible redirects
The role dashboards moved from nested paths to role-owned top-level spaces, and
the authoring screens later moved from /dashboard into /admin. These
redirects keep existing bookmarks, external links and in-flight email working.
They are 301, not 302: the move is permanent, so a browser or search engine
is correct to cache it.
| Method | URI | Name | Target | Middleware |
|---|---|---|---|---|
GET |
/dashboard/seller |
dashboard.seller.redirect |
/seller/dashboard (301) |
— |
GET |
/dashboard/admin |
dashboard.admin.redirect |
/admin/dashboard (301) |
— |
GET |
/dashboard/posts |
dashboard.posts.index |
/admin/posts (301, query string kept) |
— |
GET |
/dashboard/posts/create |
dashboard.posts.create |
/admin/posts/create (301) |
— |
GET |
/dashboard/posts/{id}/edit |
dashboard.posts.edit |
/admin/posts/{id}/edit (301) |
— |
GET |
/dashboard/comments |
dashboard.comments |
/admin/comments (301, query string kept) |
— |
GET |
/dashboard/taxonomy |
dashboard.taxonomy |
/admin/taxonomy (301) |
— |
GET |
/teams/dashboard |
team.dashboard |
/dashboard (301) |
auth, verified |
GET |
/settings/teams |
teams.index.redirect |
/settings/security (301) |
auth, verified |
Three things about the author redirects are deliberate:
- They keep the old route names as well as the old paths. A
route()call that outlives the move, or anurl.intendedwritten by the permission middleware, should resolve rather than throw. /dashboard/postsand/dashboard/commentsare closures, notRoute::redirect(). Those pages keep their filters in the query string —?status=draft,?q=caching,?post=7are the page's state, and a static redirect would silently drop them.{id}is a scalar with->whereNumber('id'), not a bound model. These paths sit outsideauth, so a route that resolved a post would answer 404 for a missing one and 301 for an existing one, telling a guest which post IDs exist. A non-numeric segment simply does not match.
The team.dashboard name is preserved as a redirect to /dashboard. It was the
starter kit's team-scoped dashboard; with the team feature removed there is no
team to scope by, but the name is still referenced in documentation and may be
bookmarked, so it resolves rather than 404s. settings/teams lands on the
security screen — the closest surviving screen, since no team surface remains.
8. Settings routes
Two groups with different guards. The split is deliberate. Account settings
are shared by every role — there is no team-scoped settings surface; the team
feature and its /settings/teams routes were removed.
| Method | URI | Name | Guard |
|---|---|---|---|
GET |
/settings |
— | auth → redirects to /settings/profile |
GET |
/settings/profile |
profile.edit |
auth only |
GET |
/settings/appearance |
appearance.edit |
auth, verified |
GET |
/settings/security |
security.edit |
auth, verified, password.confirm* |
GET |
/settings/teams |
teams.index.redirect |
auth, verified → 301 to /settings/security |
* password.confirm is applied conditionally:
->middleware(when(
Features::canManageTwoFactorAuthentication()
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
['password.confirm'],
[],
))
The security screen is where two-factor is enabled and disabled and where passkeys are managed. Requiring a re-entered password makes a stolen session insufficient to add an attacker's authenticator. If either feature is disabled in config, the middleware is dropped rather than leaving a screen unreachable behind a confirmation that guards nothing.
Why /settings/profile requires only auth. A user whose e-mail is
unverified must still be able to reach it, because changing your e-mail
address is how you recover from being unable to receive the verification mail.
Requiring verified here would make an unverifiable account permanently stuck —
unable to verify, and unable to change the address that verification is sent to.
/.well-known/passkey-endpoints is a JSON discovery document, returns
security.edit for both enroll and manage, and is intentionally
unauthenticated.
9. Passkey routes
Provided by laravel/passkeys. Two groups: unauthenticated (login) and
authenticated (registration and management).
| Method | URI | Name | Auth |
|---|---|---|---|
GET |
/passkeys/login/options |
passkey.login-options |
guest |
POST |
/passkeys/login |
passkey.login |
guest |
GET |
/user/passkeys/options |
passkey.registration-options |
auth |
POST |
/user/passkeys |
passkey.store |
auth |
DELETE |
/user/passkeys/{passkey} |
passkey.destroy |
auth |
GET |
/passkeys/confirm/options |
passkey.confirm-options |
auth |
POST |
/passkeys/confirm |
passkey.confirm |
auth |
GET |
/.well-known/passkey-endpoints |
well-known.passkeys |
public |
The options routes return WebAuthn challenge payloads. The challenge is
single-use and server-verified; a replayed assertion is rejected by the library,
not by application code.
10. Two-factor management routes
Fortify's management endpoints. All require auth; those that change state are
additionally behind password confirmation.
| Method | URI | Name |
|---|---|---|
POST |
/user/two-factor-authentication |
two-factor.enable |
DELETE |
/user/two-factor-authentication |
two-factor.disable |
GET |
/user/two-factor-qr-code |
two-factor.qr-code |
GET |
/user/two-factor-secret-key |
two-factor.secret-key |
GET |
/user/two-factor-recovery-codes |
two-factor.recovery-codes |
POST |
/user/two-factor-recovery-codes |
two-factor.regenerate-recovery-codes |
POST |
/user/confirmed-two-factor-authentication |
two-factor.confirm |
These are called by pages::settings.security, not navigated to. The setup
modal posts to two-factor.enable and then to two-factor.confirm — the two
steps being enable-then-verify, which is why a half-finished setup leaves the
account with 2FA enabled but unconfirmed, and SecurityTest covers the case
where that state persists between requests.
11. Password confirmation routes
| Method | URI | Name |
|---|---|---|
GET |
/user/confirm-password |
password.confirm |
POST |
/user/confirm-password |
password.confirm.store |
GET |
/user/confirmed-password-status |
password.confirmation |
Confirmation is time-boxed by session, not per-action. Once confirmed, the
session records the timestamp and sensitive routes accept it until it expires.
This is why GET /user/confirm-password redirects when the session has already
confirmed — the screen exists to establish the state, not to re-assert it.
12. The removed team routes
The team feature was deleted entirely, and with it every team-scoped route.
Nothing in the current route table carries a current_team or team
parameter, and no URL can reintroduce team scoping by accident.
Removed: the Team, Membership and TeamInvitation models; the HasTeams
and GeneratesUniqueTeamSlugs traits; TeamPolicy; the
EnsureTeamMembership and SetTeamUrlDefaults middleware; the
TeamPermissions and UserTeam DTOs; the TeamName and
UniqueTeamInvitation rules; Actions/Teams/* and Notifications/Teams/*;
the RedirectsToCurrentTeam response concern; the TeamRole and
TeamPermission enums; the whole resources/views/pages/teams/ tree; the
⚡team-switcher, ⚡create-team-modal and team-invitation-alert components;
the layouts/app* layouts; views/dashboard.blade.php; the create_teams_table
and add_current_team_id_to_users_table migrations; the TeamFactory and
TeamInvitationFactory; and the whole tests/Feature/Teams/ directory.
The teams, team_memberships and team_invitations tables are gone, as is
users.current_team_id. The authorisation model is now single-axis:
AccountRole + AccountPermission are the only vocabulary. Any description of
a two-axis (account + team) model is stale.
Two routes survive as redirects so old links resolve — /teams/dashboard →
/dashboard (team.dashboard) and /settings/teams → /settings/security
(teams.index.redirect); see §7.
13. Package routes (not listed individually)
| Prefix | Source | Count |
|---|---|---|
livewire-*/ |
Livewire transport, assets, file upload, upload/preview endpoints | 9 |
flux/ |
Flux asset manager (JS, editor, flags) | 9 |
storage/{path} |
Local filesystem serving | 2 |
_boost/browser-logs, up |
Laravel Boost, health check | 2 |
_dusk/* |
Laravel Dusk page/asset capture, local only |
3 |
None should be relied on by application code. livewire-* includes a build hash
that changes between releases.
14. Conventions
Route names mirror URIs, with dots for nesting.
/dashboard → dashboard.index
/admin/posts → admin.posts.index
/seller/dashboard → seller.dashboard
/admin/users → admin.users
Role homes are named <role>.dashboard, not dashboard.<role>.*, because the
role is the first path segment and the name follows the path. The redirects are
the exceptions that prove the rule: team.dashboard, teams.index.redirect and
the five dashboard.posts.* / dashboard.comments / dashboard.taxonomy names
keep labels that no longer match their target, on purpose, so old references
still resolve.
View routes use Route::livewire. The second argument is always a
pages:: or layouts:: name. A typo is a runtime 500, not a build failure —
which is why route names and their components are exercised by tests rather
than trusted.
Model binding keys are explicit. {post:slug} on public routes,
{post:id} on the /admin authoring routes. Never a bare {post}, because the
default key
silently changes if the model's getRouteKeyName() changes. No route binds a
team, because no route is team-scoped.
The one bare parameter is {attachment}. It binds by id, and that is the right
key here rather than a lapse: a feed image has no slug, its stored filename is a
UUID, and the id is not the secret — the signature is. The route it appears in is
also the only one where the bound model is not the resource a reader is looking
at, just the row that names the bytes.
Guards are permission-based, not role-based. Every role check is expressed
as permission:<ability> or a policy. There is no ->middleware('role:admin')
anywhere in the codebase, and there should not be: a route that names a role
must be edited every time a role's permissions change.
15. Known issues
| # | Issue | Impact |
|---|---|---|
| 1 | admin.posts.create and .edit differ only by a bound model |
Intentional, but means the "create" guard cannot be expressed in middleware. §4. |
| 2 | dashboard.index is unguarded by permission |
Intentional — it is the forwarding entry point for every role. §4. |
| 3 | The AI Hub's two curated shelves render placeholders, not features | Intentional. /ai-hub/dev-lab and /ai-hub/ideas need a hub-side curation model; the modules they are named after are built and have their own surfaces. §5. |
| 4 | team.dashboard and teams.index.redirect name routes that no longer match their behaviour |
Historic names kept so old links and docs resolve. §7, §14. |
| 5 | The feed has no HTTP write surface — no POST /feed, no route to a draft, no per-post edit URL |
Intentional. The composer modal owns every verb through App\Actions\Feed\*, so nothing here needs a URL yet. §2. |
| 6 | feed.attachments.show is unreachable by path and its signed links do not survive ten minutes |
Intentional. Copying an image URL into a document or a mail template will break; the reader must be entitled at request time. §2. |
On issue 3: a shelf is curation owned by the hub, not another view of the
module's rows, so it is deliberately not the same page as /ideas or
/dev-lab — and not the same page as /admin/ideas or /admin/dev-lab, which
are oversight rather than selection. Each module keeps one placeholder check in
RoutingStructureTest so that a page promising a feature it does not have is
caught by the suite rather than by a visitor.