Abdulkader Safi

A field nobody can list is a form field with no consumer

Custom fields on a page type are worth nothing until something puts them on a page. Building the block that lists them meant handing every block view the page it is rendered on, which turned out to be a one-line change to the renderer and the most useful thing in the release.

4 min read

Share
A grid of three service cards on a page, each showing a title, a sentence and a starting price.

A page type can declare a price, a card image and an excerpt. Then what? A client fills them in, and unless something on the site reads them back, they are three inputs writing to a column nobody queries.

That is the block half of v0.5.0.

Two things it needed

A query. Pages of one type, published only, in some order, optionally hand-picked.

A card. Markup that knows what the type called its fields, which the package cannot know, because the whole point of a page type is that the developer names its fields.

So the type names the partial:

public static function cardView(): string
{
    return 'services.card';
}

The block finds the pages and includes that view per item. The package ships a fallback card of title and link, so a type that declares none renders something rather than nothing, because a block that renders blank reads as broken.

The card is the one file that knows the vocabulary:

<a href="{{ $page->url($locale) }}">
    <h3>{{ $page->title }}</h3>
    <p>{{ $page->data('excerpt', $locale) }}</p>
    <p>From AED {{ number_format((float) $page->data('starting_price')) }}</p>
</a>

The missing variable

A block view received $attributes, $shared, $children, $id, $node, $locale and $editing. Not the page. For six releases nothing needed it, because a block rendered its own attributes and nothing else.

A listing needs it twice over. To exclude the current page from its own list, and to answer "the pages under this one" by slug path. And a pricing table needs it more than that: on a service page it reads that service's own tiers, so the block has no pricing fields at all.

public function render(array $tree, string $locale, bool $editing = false, ?Page $page = null): string

Optional parameter, two call sites updated, done in ten minutes. It is the change in this release I expect to matter most in a year, because it turns a block from a thing that renders its own settings into a thing that can render its context.

The demo that proves it

The example app has a pricing table block with no pricing fields:

$tiers = collect($page?->data('tiers') ?? [])->filter(...);

Edit a tier's price under Services in the panel and three places follow: the pricing table on the service's page, the "from" line on its card everywhere it is listed, and the options in the booking form below it. One field, one place to edit, three consumers. That is what the whole feature is for.

On a page with no tiers, the same block renders nothing at all rather than an empty table, so a client can drop it anywhere without breaking a layout.

Hand-picking, and the trap inside it

The first version listed everything published. Then came the obvious follow-up: choose which four services appear on the homepage, in a specific order.

A multi-select gives you the set and not the order. A repeater of selects gives you both, with drag handles, in about the same amount of code:

Repeater::make('pages')
    ->simple(Select::make('page')->options(...)->searchable())
    ->reorderable()

The options closure has to read the type chosen in the select above the repeater, from inside a repeater item, which is a relative path two levels up. Get that path wrong and nothing errors: the options resolve to nothing and the picker is silently empty.

So the fallback is not an empty list. With no type resolvable it offers every typed page with its type in the label, which is worse UX and never a dead end. Then a test locks the happy path by asserting the absence of that fallback label:

expect($html)->toContain('Web design')
    ->not->toContain('Web design (Service)');

A test that asserts what should not be on the page, because the failure mode is silence.

Published only, in the editor too

One rule worth stating: the listing shows published pages in the editor preview as well as on the live site. A preview that lists half-finished drafts is a preview of a site that does not exist, and the whole argument for this editor is that what you see is the page.

Where it stands

Shipped in v0.5.0. A Collection block that lists a type, everything published or hand-picked in a dragged order or the children of the current page, each rendered through the type's own card.

The renderer change is the part I would keep if I could only keep one line of this release.

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

Every entry on this project

28 build notes, in order

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

Read the series →

FAQ

Frequently asked questions

Where should the markup for a listed item live in a page builder?

In a partial owned by whoever defined the fields, named by the content type rather than hardcoded in the listing block. The listing block can know how to query and how to lay out a grid, but it cannot know that this project called a field starting_price, so any attempt to render an item inside it ends in a configuration screen for field names. Naming a partial keeps the block generic and the markup specific, and it means restyling every listing of that type is one file. Ship a minimal fallback partial so a type that declares none still renders.

Should a component in a page builder be able to read the page it is placed on?

Yes, and passing it costs almost nothing. Without it every component can only render its own settings, so anything contextual has to be retyped into the component: a pricing table on a product page would need the prices entered twice, once on the product and once in the block. With the page available, a component can read the record's own fields and stay in sync by construction. Keep the parameter optional so a component can still be rendered outside a page context, and guard for null in the view.

Multi-select or a repeater when an editor needs to pick items in a specific order?

A repeater of single selects, because a multi-select gives you a set and not a sequence. Most implementations append to the end and offer no way to move an entry, so ordering means removing everything and re-adding it in order, which nobody discovers on their own. A repeater with drag handles stores a list whose order is the render order, and a simple repeater with one field inside keeps the stored shape flat. Sort the query results by the stored order rather than by whatever the database returns.

How do you test a failure mode that produces no error?

Assert the absence of the fallback rather than the presence of the right answer. If a lookup falls back silently when it fails, both paths render something plausible, so a test that only checks the good value can pass while the code takes the wrong branch. Make the fallback distinguishable, even by something small like a label suffix, and assert that the distinguishing detail is not there. It also helps to make the fallback useful rather than empty, so the silent failure degrades into worse behaviour instead of a blank screen.

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.