Abdulkader Safi

The demo needed a booking form, and a form needs someone to not trust

The example app grew into a whole cleaning company: services with pricing tiers, products with variations, and forms that turn a page into a lead. One rule survived every shortcut the demo tempted me into, and it is the only part of it I wrote tests for first.

4 min read

Share
A pricing table with three tiers above a booking form, with the suggested tier already selected.

v0.5.0's example app is a small cleaning company. Five ordinary pages, three services with three pricing tiers each, three products with variations, and a Requests inbox in the panel. No payment gateway: a request is a lead, and the client works it by hand.

Building it was mostly pleasant. One part was not, and it is the part worth writing down.

The shape of the form

A service page shows its tiers and a form below. Pick a package, leave a name and an email, send. A product page does the same with variations and a quantity. The contact page has no options at all.

One block does all three, because it can read the page it sits on:

$options = collect(match ($page?->type) {
    'service' => $page->data('tiers') ?? [],
    'product' => $page->data('variations') ?? [],
    default => [],
});

The obvious way to price that is a hidden input. The selected tier costs 750, so post 750 alongside it and store what arrives. Every tutorial form does this, and it is wrong in a way that costs money rather than crashing.

What the controller actually does

The form posts an option name. Nothing else about money leaves the browser.

$rows = $kind === 'product'
    ? ($page->data('variations') ?? [])
    : ($page->data('tiers') ?? []);

foreach ($rows as $row) {
    if (($row['name'] ?? null) === $option) {
        return [$option, isset($row['price']) ? (float) $row['price'] : null];
    }
}

return [null, null];

The price is read off the page the request names. An option the page does not offer is dropped rather than trusted, and the request still lands, because a mismatch is worth a phone call and not a 422 in a stranger's face.

The test that matters says it plainly:

it('ignores a price sent from the browser', function () {
    post('/request', [
        'page_id' => $service->getKey(),
        'option' => 'Villa',
        'unit_price' => 1,
        'total' => 1,
        // ...
    ]);

    expect((float) Enquiry::firstOrFail()->total)->toBe(1400.0);
});

It is a demo. Nobody is attacking a seeded SQLite database on my laptop. I wrote it anyway, because the example app is what people copy, and a hidden price input copied into a real project is a real discount for anyone who opens developer tools.

The rest of the trust boundary

Four more things, all small, all in the same controller:

  • Draft pages 404. A request against an unpublished page is rejected, so a page being written cannot take orders.
  • A honeypot field that a person never sees and a bot fills in, validated as prohibited, which is a rule Laravel already has.
  • Rate limiting, because it is the only unauthenticated write the site has.
  • Quantity capped at 99 and validated as an integer, so the total is a number and not a surprise.

None of that is clever. It is the list you write down once and then apply every time, and the reason to write it in a demo is that a demo is a template whether you meant it to be or not.

What the panel does with them

One resource, tabs for services, products, unanswered and won. Three stats on the dashboard: how many of each came in, and what the won ones add up to. The fields are all disabled except the status.

That last bit was deliberate. A request is a record of what a stranger typed, and a screen that lets staff edit the message would make it evidence of nothing. The only editable thing is where it got to.

The part the package still refuses to decide

Atelier ships no form block. Where a submission should land, a table, an email, a CRM, is an open question in the spec and has been since the block library was planned. This whole thing lives in the example app: its own model, its own migration, its own controller, its own resource.

That is not procrastination, it is the honest answer. A package that picks one destination is wrong for two thirds of the projects that install it, and a package that supports all three has a configuration surface bigger than the feature. Meanwhile the example shows exactly how to wire the version you need, which is roughly two hundred lines including the panel screen.

Where it stands

Shipped alongside v0.5.0, in example/. A demo you can click through end to end: pick a tier, send the form, watch it appear in the panel, change the price in the panel, watch three places on the public site follow.

Every block it uses is defined in the example app rather than pulled from the package, so the folder reads on its own. That was the last request before the release, and it is the one that made the example worth having.

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

Every entry on this project

28 build notes, in order

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

Read the series →

FAQ

Frequently asked questions

Why should a price never come from the submitted form, even with validation?

Because validation checks the shape of a value, not its truth. A hidden input saying the total is one passes every numeric rule you can write, and the only source that can contradict it is the record the order refers to. Post an identifier for what was chosen, look the price up server-side, and compute the total there. The same applies to discounts, quantities with tiered pricing and anything else where the browser is doing arithmetic you will later bill on.

How should a server handle an option that does not exist on the record?

It depends on whether the request is still useful without it. For a lead or an enquiry, drop the unrecognised option, keep the submission, and let a person follow up, because rejecting the whole thing loses a customer over a stale page or a browser autofill. For an order that will be charged, refuse it, because guessing what someone meant to buy is worse than asking. Either way, do not silently substitute a default option, which produces a record that looks deliberate and is not.

Should demo and example applications hold to production standards?

For anything at a trust boundary, yes, because an example is a template. People copy the controller that handles the form, not the paragraph in the README warning them it was illustrative. Validation, authorisation, rate limiting and not trusting client-side values cost a few lines each and they teach the right shape. Fixtures, seeded passwords and simplified styling can stay obviously fake, since nobody copies those into production by accident.

When should a package leave an integration point undecided?

When the reasonable answers differ per project and supporting all of them costs more than the feature. Where a form submission should go is the classic case: a table, an email, a CRM webhook, and each project has an opinion. A package that hardcodes one is wrong most of the time, and one that abstracts all three grows a configuration surface bigger than the thing being configured. Ship the extension point, and ship a worked example of one implementation so the missing piece is a copy rather than a design problem.

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.