Every product I have worked on grew the same way.
First comes the admin panel. Forms, validation, "editors may publish, authors may only write drafts", a slug that must be unique, an email when an order is placed. It works.
Then the client wants a mobile app. So an API appears next to the admin. The rules get written again in API controllers β mostly the same, slightly different. The coupon that applies to one category works in the admin but not in the app, and nobody notices for a month.
Then somebody wants AI to do the boring work: fill in missing meta descriptions, redirect the pages that return 404, answer "how did the shop do this week". So a third set of endpoints appears, with a third copy of the rules, and a big question nobody wants to answer: what exactly is this agent allowed to touch?
Three backends. Three places where "an author may not publish" is written down. Three places to forget.
We build kitloom, a Laravel CMS with Filament or Nova as the admin, a site in Blade, Vue or React, an MCP server for AI agents and, since this month, a phone app for its shops. We could not afford three backends per feature β kitloom has about seventy modules, each with two admins. So we made one rule early and kept it:
The admin is not a UI. The admin is a write path. Every screen, endpoint, tool and app is a thin door in front of it.
This post is about what that rule looks like in code, and what it bought us: an AI agent with 200+ tools that are safe by construction, and a mobile app whose backend took two days.
Filament Nova REST /api/ MCP agent Phone app
β β β β β
βββββββββββββ΄ββββ thin doors: forms, controllers, tools β
β
writers + permissions + events, with an actor
(PostWriter, TermWriter, Cart, Checkout, Redirectionsβ¦)
β
Eloquent models β database
Three rules keep the doors thin.
Not Post::create(). Not $post->update() in a controller. A writer:
$post = $this->writer->create('post', [
'title' => 'Spring collection',
'content' => $html,
'status' => 'publish',
], $actor);
PostWriter makes the slug unique, fills the dates, assigns the terms, cleans the HTML of a user who may not post scripts, fires PostSaved / PostPublished β and refuses what $actor may not do.
In our code base this exact call is made by the Filament create page, the Nova resource, the REST API's PostsController and the AI agent's create_content tool. Four doors, one place where the rules live: a fix to how slugs are made is a fix for all four.
The shop does the same: the site's checkout form and the phone's POST /checkout both end in Checkout::placeOrder(); the site's cart and the phone's cart quote are both priced by the shop's own Cart. A coupon, a shipping zone, "free delivery from $60" β written once.
A module does not know the agent exists. It offers a class and tags it:
// RedirectsServiceProvider::register()
$this->app->tag([
RedirectsList::class, RedirectsCreate::class, RedirectsUpdate::class, RedirectsTrash::class,
NotFoundList::class, RedirectFromNotFound::class,
], AgentTool::TAG);
And the tool itself is a door β about forty lines, almost all of it description:
// Trimmed: module() and schema() left out.
final readonly class RedirectsCreate implements AgentTool
{
public function __construct(private Redirections $redirections, private RedirectData $data) {}
public function name(): string { return 'redirects_create'; }
public function description(): string
{
return 'A new redirect rule, active at once: the paths it catches, where they go, '
.'the code (301 by default). Look with redirects_list first: a rule for the path may exist.';
}
public function permission(): string { return Redirections::PERMISSION; }
public function writes(): bool { return true; }
public function handle(array $arguments, Authenticatable $actor): array
{
return $this->data->present(
$this->redirections->create($this->data->attributes($arguments), $actor)
);
}
}
The AI module collects everything tagged AgentTool::TAG and serves it over MCP. Without the AI module, nothing reads the tag and nothing breaks.
The same trick works for everything that crosses modules: account sections (AccountSection::TAG), notification topics, webhook topics, columns on every list in the admin, permissions. grep -rn 'const TAG' is our extension-point catalogue.
The site's Vue and React themes are Inertia, so every page has its data as JSON anyway. We made it public: ask any page with Accept: application/json and you get exactly what the theme draws β a product, a category with its filters, a search, the front page.
That turned out to be the most important decision for the phone app, and we did not make it for the phone app.
Because a tool is a door to a writer, and a writer already asks "may this user do this?", the agent's security model is not a separate system. It is the user's.
writes(): false tools are the only ones a read-only key ever sees.
Today that is 209 tools across the modules: content, SEO, redirects and the 404 log, menus, forms and their messages, the shop's products and orders, bookings, translations, backups, Site Health⦠Most are a few dozen lines, because the logic was already there. You can ask for whole jobs:
And the editor can read in the activity log exactly what happened.
This is the part that surprised us.
The app is one Expo (React Native) project for any kitloom shop: a config file with the site's address, name, colours and icon, and an EAS build under the shop owner's own Apple and Google accounts. Home with banners and shelves, a catalogue with the site's filters, product pages with galleries and reviews, a cart, a three-step checkout, the customer's account, push when the order ships.
The site's side of it β the mobile-apps module β is about 2,200 lines of PHP in src/, plus its tests. Here is why it is that small:
| The phone needs | Where it comes from | New backend code |
|---|---|---|
| Catalogue, product, search, front page | the site's own pages as JSON | none |
| Filters (style, strength, priceβ¦) | the category page's JSON | none |
| Cart totals, coupons, shipping | POST /cart/quote β the shop'sCart |
a thin controller |
| Checkout | POST /checkout βCheckout::placeOrder() |
a thin controller |
| Sign-in | the login form's own PasswordCheck (the same locks against guessing) + an API token |
extracted, not rewritten |
| Account: orders, addresses, downloads, bookings | whatever is tagged AccountSection::TAG |
none |
| "Your order is on its way" | a listener on the shop's OrderStatusChanged β queued push via Expo |
new |
| Phones list, push settings, install links | a Filament page + a Nova tool | new, started from the generator |
The account row is my favourite. The app names no module. When a shop installs the bookings module, its Appointments section appears on the site's account page and on the phone, with no app release, because the app draws whatever sections the site lists.
From the approved plan to the module in a tagged release took two days, with the first Android build on a real phone at the end of the second. The admin screens for it exist in both Filament and Nova, because the generator writes both.
There is a second reason the timings above are short, and it is the same architecture seen from the other side.
We write most of kitloom with an AI coding assistant (Claude Code). The main lesson so far: an AI assistant copies a pattern very well and invents one badly. Ask it to "add a mobile API" to a code base with three ways of doing writes, and it will happily add a fourth.
So we spent the effort on making every kind of change have exactly one pattern, and on writing the patterns down:
AGENTS.md at the repository root.php artisan kitloom:make-module Reviews writes the domain package and both admin packages β provider, migration, model registration, permissions, settings page, agent tools, tests β and the result passes every check as generated. The assistant starts from correct code instead of from a blank file.tools/check.sh runs package-dependency checks (a module may use another's classes only if it requires it), PSR-4, Pint, PHPStan with a baseline that may not grow, and the tests of both admins. --affected runs only what a change can reach, so the loop stays fast; the full run is the gate before a commit.mobile-apps and runs The thin-door architecture and the AI assistant reinforce each other. When the right place for logic is always "the writer", the assistant puts it there. When a new door is always "a class with a tag", the assistant writes the class and the tag. The review is then about what the code does, not where it went.
It is not free, and I'd rather say so:
$model->save() in a controller "just this once", the guarantees are gone. Our checks don't see it; code review has to.PostWriter knows a lot. Revisions and HTML cleaning already live in classes of their own, and it is still one of the biggest classes we have.app/v1) and the app feature-detects what the site supports.
None of these is a reason to go back to three backends.
If you have built something similar β one write path behind several clients β I'd like to hear where it broke for you. Ours broke at "just this once".