Abdulkader Safi

Services needed their own sidebar entry, and Filament had already solved it

The ask was simple: services and products should sit in the sidebar next to Pages, not hide behind a dropdown on a page form. The design I approved cost a Filament resource subclass per type in every app that installs the package. Reading the framework source before writing it found the mechanism that removes the subclass entirely.

5 min read

Share
A Filament sidebar showing Pages, Services and Products as separate entries, each with its own icon.

v0.5.0 adds page types. A service, a product or a case study becomes its own entry in the panel sidebar, with its own fields and its own starter sections, on the same table and the same editor as every other page.

The ask

Atelier already had a dropdown on the page settings screen labelled "Page type". It set the schema.org type in the JSON-LD and it told the panel nothing. A page that was a Service in search results was a row under Pages in the sidebar, next to the privacy policy.

What was actually wanted: Services in the sidebar, with its own icon, listing only services, each with the handful of extra facts a service carries. Products the same. Pages keeps everything else.

What a type turned out to be

Once the questions were answered, a page type is six things a developer declares in code:

  • the custom properties, as a plain Filament schema
  • which of them are translated
  • the block tree a new one opens with
  • the blocks its picker offers
  • a slug prefix, and a schema.org type
  • the Blade partial a listing renders it through

None of that needs a new table. A typed page is still a row in atelier_pages, so it inherits slugs, redirects, revisions, drafts, preview, SEO and the sitemap without a line of work. Two columns carry the difference: type, defaulting to page, and data for whatever the type declared.

The cost I was about to accept

A Filament resource is routed by class name. That is the whole problem. /admin/services and /admin/pages are two routes, two navigation items and two breadcrumb trails, and Filament resolves each to a class, so one class cannot wear two hats.

So the approved design had every consumer writing a subclass per type:

class ServiceResource extends PageTypeResource
{
    protected static string $pageType = 'service';
}

Five lines each, times every type, in every client project. Annoying rather than fatal, and I had already argued myself into it.

What version 5 already had

Before writing it I went reading vendor/filament/filament/src/Resources to check how routes get their names. Two files down there is ResourceConfiguration, a small class holding a resource class, a key and a slug. Panel::resources() accepts instances of it as well as class strings. Resource::getSlug() prefers the configuration's slug over the class-derived one.

Which means the framework already supports registering one resource class several times, each under its own key and URL. The panel builds routes for each configuration and stamps them with a middleware:

$route?->middleware("resource-configuration:{$configuration->getKey()}");

That middleware sets the current key, and it is registered as a Livewire persistent middleware, so the key survives every Livewire update after the first load. Table sorting, pagination and a save all still know which type they are looking at.

The registration became a loop:

$resources = [PageResource::class];

foreach (app(PageTypeRegistry::class)->all() as $key => $type) {
    $resources[] = PageResource::make($key)->slug($type::slug());
}

One resource class, one plain registration for Pages and one configuration per type. Everything inside the resource asks which hat it is wearing:

public static function typeKey(): string
{
    return static::getConfiguration()?->getKey() ?? PageTypeRegistry::DEFAULT;
}

Query scope, label, icon, navigation sort, the extra form section and the defaults on create all hang off that one method.

What it bought

A page type is now one plain PHP class that knows nothing about Filament resources. That fixed a wart I had flagged when presenting the design and could not remove: cardView(), a front-end concern, was going to live on a resource class because the resource was the only class per type. With the configuration approach the type is not a resource, so the method sits where it belongs.

It also removed a file per type from every project that will ever install this.

The one thing to know before copying it

The current configuration key lives on Filament's manager singleton and nothing resets it per request. That is correct for a normal PHP request, where the container is thrown away at the end. It is not correct inside a test, where two get() calls share one container: hit /admin/services and then /admin/pages in the same test and Pages lists services, because the key from the first request is still set.

The fix in tests is one line clearing it between requests, and it is worth knowing if you run Octane, where containers are also reused.

Where it stands

Shipped in v0.5.0. Two columns, one registry, no resource class per type, and Pages still lists anything carrying a type nobody registered, so deleting a type class never hides a live page from the panel.

Reading the framework source for twenty minutes deleted a file from every future project. I nearly wrote the subclass version instead, because I had already argued for it in front of someone and the design was approved.

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

Every entry on this project

28 build notes, in order

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

Read the series →

FAQ

Frequently asked questions

When should one admin resource class serve several navigation entries instead of subclassing it?

When the entries differ by data rather than by behaviour. If two sidebar entries list the same model, use the same form and differ only in a filter, a label and an icon, a subclass per entry duplicates a file to hold three values. Check whether the framework has a first-class way to register one class several times with per-registration configuration, because several admin frameworks added one and it is easy to miss. Subclass when the screens genuinely diverge: different columns, different actions, different permissions. The test is whether you would still want the second class if the framework did not force it on you.

How does per-route context survive a Livewire or AJAX update, which does not hit the original route?

It has to be re-established on the update request, because that request goes to the component endpoint rather than to your route. Livewire's answer is persistent middleware: middleware registered as persistent runs again on every component update, so anything it sets, such as the current tenant or the active configuration, is present again. If you set request-scoped context in ordinary route middleware and read it inside a component, it will be there on first paint and gone on the first interaction, which reads as a bug that only happens when you click something.

Should a new content type get its own table and model?

Only when its lifecycle differs. If the new type wants the same URLs, the same draft and publish flow, the same revisions and the same SEO fields as an existing model, a type column and a JSON column for its extra fields give you all of that for free, and one editor to maintain instead of two. Separate tables earn their keep when the relationships differ, when the volume differs by orders of magnitude, or when one type needs columns the other would leave null forever. Reaching for a second table first usually means reimplementing publishing twice.

What should happen to existing rows when a developer deletes the type that defined them?

They should stay visible and keep working. The rows outlive the class: content sits in a database and the code that described it is edited every week. Resolve an unknown type to null rather than throwing, fall back to a sensible default for rendering, and make sure some screen in the admin still lists the orphans, otherwise you get pages that are live on the public site and invisible to the person who is supposed to manage them. The same rule applies to any stored key pointing at something registered in code, such as a layout or a block type.

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.