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.
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.