One write path, five front doors: how our Laravel admin got an AI agent and a mobile app without a second backend
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 wo
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.
The shape
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.
1. Every write goes through a writer, and the writer gets the acting user
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.
2. Modules plug in through container tags, not through each other
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.
3. Every page already answers as JSON
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.
What it bought us: the AI agent
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.
- The agent signs in (OAuth, or a key) as a user. An author's agent writes drafts. A read-only key gets only the reading tools.
-
writes(): falsetools are the only ones a read-only key ever sees. - Nothing is deleted for good: tools trash and restore, permanent deletion stays with a person.
- Every change goes through the writers, so it lands in the activity log under the agent's user β the same log that shows what editors did.
- Settings that decide who gets in (roles, registration, two-factor, agent access itself) are locked for agents, because a page the agent reads could try to talk it into changing them.
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:
- "Which addresses gave 404 this week? Redirect the ones that have a new page."
- "Fill in the SEO descriptions of the products that have none β as drafts, I'll check them."
- "How did the shop do this month, and which products sold best?"
And the editor can read in the activity log exactly what happened.
What it bought us: the mobile app
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's Cart
|
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.
And the other AI: the one writing the code
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.mdat the repository root. Not a style guide β a list of "to add X, do Y": a module, a permission, a REST namespace, an agent tool, an account section, a notification. Every rule says where the example lives in the code. The assistant reads it before the first edit. -
Generators instead of copying.
php artisan kitloom:make-module Reviewswrites 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. -
Checks that say no.
tools/check.shruns 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.--affectedruns only what a change can reach, so the loop stays fast; the full run is the gate before a commit. -
Tests live in the package. The assistant changes
mobile-appsand runsmobile-apps's tests, not the whole suite.
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.
What it costs
It is not free, and I'd rather say so:
-
Discipline at the doors. The moment someone writes
$model->save()in a controller "just this once", the guarantees are gone. Our checks don't see it; code review has to. -
Writers grow.
PostWriterknows a lot. Revisions and HTML cleaning already live in classes of their own, and it is still one of the biggest classes we have. -
The app depends on page JSON. Changing what a product page returns is now an API change. We version the app's own endpoints (
app/v1) and the app feature-detects what the site supports. - Platforms have their own rules. Apple wants digital goods sold through its own purchases, so the app does not sell downloads; an account with a second factor is asked to sign in on the site for now.
None of these is a reason to go back to three backends.
Try it
- What kitloom is, its modules, themes and editions: kitloom.dev.
- A sandbox with demo content, one click, no sign-up: Filament or Nova. Give your Claude or Cursor its address and let it work.
- The shop the app runs against: Prairie Hops, a demo craft-beer store.
- How the agent is wired: Run your site with an AI agent. How the app is wired: A mobile app for your online store.
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".
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.