Page Types, Slugs and Public URLs

Two related mechanisms decide what a URL shows. Page types (AppPages) let an administrator say "this CMS page is my Shop page". Resolvable slugs let your own records live at /some-slug next to pages and posts.

Part 1 — Page types (AppPages)

What problem it solves

Homepage, blog index, contact page, privacy page and terms page are not special models: they are ordinary Page rows chosen in Settings → Page setup. A module can offer the same choice for its own screens (Shop, Cart, Booking services, My bookings…) so the site owner designs those pages in the page builder, while your code keeps its routes and logic.

Settings → Page setup   (one tab per module group)
   "Shop page"   [ select a published CMS page v ]  <-- stored as setting 'trucommerce_page_shop' = page id
                          |
AppPages::selectedPage('trucommerce.shop')  -> Page model   |  ::selectedPageUrl(...) -> URL
Admin Pages list shows a label next to that page ("Shop") and opens the right editor/link
Menu builder links to it by route instead of /slug
Module controller redirects visitors from /shop to the chosen page (optional)

Registering a page type

Provider boot()

use App\Facades\AppPages;
use App\Services\AppPages\PageDefinition;

AppPages::register(
    PageDefinition::make('playground.home')                       // "{group}.{name}" -> tab "Playground"
        ->label(static fn (): string => __('playground::p.home'))   // shown in the admin Pages list
        ->selectedBySetting('playground_page_home')               // setting key that stores the page id
        ->pageSetup(                                              // expose it on Settings -> Page setup
            __('playground::p.home_page'),                          //   field label
            __('playground::p.home_page_help'),                     //   tooltip
            false,                                                  //   required?
        )
        ->pageSetupGroup(__('playground::p.group'))                // tab title (derived from the key prefix otherwise)
        ->route('playground.index'),                              // the real route this page type stands for
);
MethodPurpose
make($key)Unique key. A prefix before the first dot (trucommerce.) groups definitions into one Page-setup tab; no dot means the "core" tab
label(string|Closure)Human label; a closure receives the Page
selectedBySetting($key, $default = null)Reads the selection from setting($key) (page id or slug). Alternatively selectedPage(int|string|Closure) for a fixed or computed page
pageSetup($label, $tooltip, $required)Adds the select to the Page-setup screen; requires selectedBySetting() first. The saved value is validated as integer|exists:pages,id
pageSetupGroup($label)Tab title for the group
route($name), parameters(array|Closure), link(string|Closure)How to link to the feature when the CMS page is not used (routes need not exist; a missing route yields no link)
builder(bool)Whether the admin should offer the page-builder editor for the assigned page (default true)
matchUsing(Closure)Custom rule deciding whether a Page is the selected one

Using the selection

AppPages::selectedPage('playground.home');        // ?Page (published only)
AppPages::selectedPageUrl('playground.home');     // ?string public URL
AppPages::firstForPage($page);                    // ['key' => ..., 'label' => ..., 'link' => ..., 'route' => ..., 'builder' => true]
AppPages::forPage($page);                         // every type that points at this page
AppPages::all();                                  // every definition resolved (settings screens, debugging)

The helper getPageType($page) (used by the admin page list and menu builder) wraps firstForPage(). To make a module route follow the choice, redirect in the controller — see the redirector pattern in Routes, controllers and views.

Design tip

Make the module screen work without an assigned page (sensible default layout) and let the CMS page be an optional upgrade, like the bundled modules do. Ship a template-library layout that already contains your shortcodes so administrators can start from it (Template library).

Part 2 — Resolvable slugs

The mechanism

  1. The catch-all route /{slug} (resolve.show) calls SlugResolver::render().
  2. It looks the slug up in the global slugs table (sluggable_type + sluggable_id + unique slug).
  3. It asks the model for resolvedSlugRenderer(), instantiates that class from the container and calls render($request, $model).
  4. The renderer returns a view or response. That is the whole contract.

Because slugs are unique across all models, pages, posts, services and your records can never collide, and the reserved list (admin, api, build, storage, themes, vendor, up, every first route segment, every module's public_paths and lower-case name) is enforced for all of them.

Make a model resolvable

Modules/Playground/app/Models/Note.php

use App\Contracts\ResolvableSlug;
use App\Models\BaseModel;
use App\Traits\HasSlug;

class Note extends BaseModel implements ResolvableSlug
{
    use HasSlug;                                   // slug sync, route binding, scopes, URL helpers

    protected $table = 'playground_notes';        // note: no "slug" column is needed
    protected $fillable = ['title', 'body'];

    public function resolvedSlugRenderer(): string
    {
        return NoteSlugRenderer::class;
    }
}

Modules/Playground/app/Services/NoteSlugRenderer.php

use App\Contracts\ResolvableSlug;
use App\Contracts\ResolvedSlugRenderer;

final class NoteSlugRenderer implements ResolvedSlugRenderer
{
    public function render(Request $request, ResolvableSlug $resource): mixed
    {
        if (! $resource instanceof Note) {
            throw new NotFoundHttpException;
        }

        Meta::setTitle($resource->title);

        return view('playground::front.note', ['note' => $resource]);   // an <x-front-layout> view
    }
}

What HasSlug does for you:

Back-fill existing rows

If rows existed before the model gained HasSlug, or you import data directly, run php artisan slugs:backfill. It iterates config('slugs.models'), which in the core file lists Page, Post and Booking's Service. Add your model from the provider; no core edit is needed:

Provider boot()

config(['slugs.models' => array_values(array_unique([
    ...(array) config('slugs.models', []),
    Note::class,
]))]);

Legacy slug columns

Core models Page and Post still have a physical slug column, kept in sync by getResolvableSlugColumn() for historical reasons. New models should return null (the default) and keep the slug only in the slugs table.

Reserving URL space for ordinary routes

If your plugin has its own routes under /my-feature/..., add "public_paths": ["my-feature"] to module.json. The segment joins the reserved list and is excluded from the catch-all pattern. The first segment of every other registered route is reserved automatically too, because ReservedSlugRegistry reads all registered routes.

When you add content links to the menu builder use route('resolve.show', ['slug' => $model->slug]) (see Booking's BookingBuilderMenu), and for CMS pages prefer the page type route so renaming a slug never breaks a menu (Menus and navigation).