This is part of a series where I pull a working SaaS core out of a CRM I built and run, and ship it in public as a versioned Composer package, one module at a time. Previous phases covered auth, multi-tenancy, RBAC, the activity log, and multilanguage. This one is navigation: a menu engine, and the first real screen of the operator console sitting on top of it.
It is also the phase where I found a one-line security hole that had been sitting in the donor CRM the whole time, in production, unnoticed. Any admin could log in as any user, and nothing was written down. More on that below, because it is the most useful part of the story.
What this phase is
Two things, built together on purpose:
- A navigation engine: a way to declare menu items, filter them by who is looking, and render them. Permission-aware, and extensible by the host app without editing the core.
- The first populated operator-console screen: admin user management (list, search, edit, block, soft-delete, restore) plus impersonation.
The activity log phase had already planted a minimal admin shell with an empty navigation slot. This phase fills it.
I built navigation and one real screen together for a reason. A menu engine with nothing behind it is a demo. Wiring it to an actual screen the moment it exists is the only way to know the abstraction is the right shape.
Decision one: the backend builds and filters the menu
The tempting way to do a permission-aware menu in an Inertia or SPA app is to ship the whole menu plus the user's permissions to the frontend, and let Vue hide what they cannot use. It is easy and it feels fine.
It is also a leak. If the filtering happens in JavaScript, the full menu is in the payload. Every route name, every admin-only section, every internal screen the user is not supposed to know exists, all sitting in the page props for anyone who opens devtools. You are not hiding the links, you are styling them as display: none.
So the engine builds and filters on the backend. A MenuBuilder collects items from providers, drops anything the current user may not see, sorts what survives, and serializes only that to the frontend. Vue renders what it is handed. The links a user cannot reach never reach the client.
The filtering is recursive into submenus, and a pure group (a parent with no destination of its own) whose children all got filtered away is itself dropped, so you never get an empty dropdown that opens onto nothing.
Decision two: the host extends with a provider, not a config array
The donor CRM built its menu as a hardcoded array inside one ~1000-line service method. Admin items and tenant items lived in the same method, split by an if (isAdmin()). Adding a screen meant editing that method. It worked, for one app, owned by one person.
A reusable core cannot do that. The host app has its own screens the core has never heard of, and it must be able to add them to the menu without forking the core or editing a config file by hand.
So menu items come from providers. The core ships providers for its own surfaces (the admin console, the tenant screens it owns). The host writes a class, registers it, and its items merge in. The exact same additive seam I used for permissions and for activity-log events in earlier phases. A PHP provider rather than a config array, deliberately, so items can be conditional, badged, or computed at request time.
In the host app I dogfooded this. The host registers a provider that adds its dashboard to the tenant menu, in one small class, touching nothing in the core:
class HostMenuProvider implements MenuProviderInterface
{
public function getMenuItems(string $level): array
{
if (! $this->supports($level)) {
return [];
}
return [
new MenuItem(
labelKey: 'Dashboard',
route: 'dashboard',
icon: 'dashboard',
order: 10,
),
];
}
public function supports(string $level): bool
{
return $level === 'tenant';
}
public function priority(): int
{
return 200;
}
}
Register it in a service provider, and it shows up. The integration test asserts the host item appears in the tenant menu next to the core's Employees and Roles items, which is the whole point: the host grew the menu without the core knowing.
A detail I changed from the donor: labels are i18n keys, not translated strings
Small but it matters, and it connects to the previous phase. The donor baked the translation into the menu on the backend: 'linkName' => __('Orders'). The label was a finished string by the time it left PHP.
That fights the language switcher I shipped in the previous phase. If the label is already translated on the server, switching language in the UI does not repaint the menu until a full reload, because the menu was frozen in whatever language the page was rendered in.
So a menu item carries a label key, not a translation. The backend ships the key (English-as-key, the same convention as the rest of the frontend), and Vue runs the translation at render. Switch language, the menu repaints live. It is the single place I deviated from the donor's menu design, and it was forced by a decision from a different phase. The modules are starting to constrain each other, which is what you want.
The part worth reading: the impersonation hole
Impersonation, "follow into a user", is on the feature list because it is genuinely useful for support. A user reports a bug you cannot reproduce, you step into their account, you see what they see. The donor had it, built on a popular impersonation package.
Here is what the donor's authorization check looked like:
// public function canImpersonate()
// {
// return $this->isAdmin();
// }
Commented out. The whole method. The impersonation package, when you do not implement its canImpersonate() hook, defaults to allowing it. So with the method commented out, the effective rule was: anyone the package was installed for could impersonate anyone. Any admin could become any user. There was no check for who the target was, and no log entry recording that it happened.
In the donor that was a single-tenant CRM with one trusted admin, so it never bit. In a reusable core shipped to people I will never meet, it is a loaded gun. This is exactly why the workflow for every module is extract, then modernize, then review for security, because the donor code is mine and self-taught and I do not get to assume it is safe.
The rebuild closes it with an actual policy, and the policy is stricter than the obvious version:
- Only a super-admin may impersonate at all.
- A super-admin can never impersonate another admin. No admin-via-admin takeover.
- A blocked or deleted account cannot be impersonated.
- Nobody impersonates themselves.
The "never another admin" rule had a subtlety I almost got wrong. The core lets you optionally pin which email is the operating super-admin, as defense in depth, so a flipped is_admin flag alone does not grant admin powers in production. But for the "cannot impersonate an admin" rule, you must block every account that carries the admin flag, allow-listed or not. If you only blocked the allow-listed admin, a flagged-but-not-allow-listed admin account would still be impersonable, and stepping into it would hand you an admin session through the back door. So the target check is on the raw flag, not on the narrowed allow-list. Two different questions ("who acts as admin" vs "who must never be impersonated"), two different checks.
And both take and leave are now written to the activity log: who impersonated whom, and when. The donor logged neither. If someone steps into a user's account, there is a record.
A few more touches that came out of building it:
- Blocking a user kills their tracked sessions. The donor set a blocked flag and trusted the next-request middleware to enforce it. But the lingering session rows meant the block did not really take hold until the user's next page load. Now blocking clears their sessions so it bites immediately.
- The session id rotates on every identity swap. Taking and leaving impersonation both regenerate the session, as defense against fixation across the identity change.
- The admin user list drops the social links the donor showed for every user. An operator list is for moderation, not profiling.
What v0.7.0 is not
Same honesty section as every release.
The operator console is one screen right now: user management. Admin companies and the admin dashboard are deliberately not here. The donor's company screen is roughly 80 percent subscription, plan, and payment data, and the dashboard is half metrics for modules that do not exist yet. Both depend on billing, which is a later phase, so building them now would mean shipping a stub and rewriting it. They come with billing, built once, correctly.
There is no operator-level permission system inside the console yet, it is super-admin or nothing. Breadcrumbs are deferred. The stronger login gate for the admin zone (a one-time-code step) is its own auth phase, not this one.
The thread, again
Every phase in this series has the same shape. The interesting code is fine. The problem is at a seam, or in a default, or in something that was commented out and forgotten. This time it was a commented-out method that turned a support feature into "any admin is every user", quietly, for as long as the donor had been running.
The menu engine was the work. The impersonation policy was the point.
Code is on GitHub, the package is versioned, every module ships with Pest tests and a security pass before it merges. Next up is billing, which is what unlocks the rest of the operator console.
LaraFoundry
A reusable SaaS/CRM core for Laravel, extracted in public from a production system.
LaraFoundry is a modular SaaS foundation being extracted from Kohana.io, a real production CRM/ERP. The goal is to package the cross-cutting parts every SaaS rebuilds from scratch (auth, multi-tenancy, i18n, admin, billing) as a clean, tested Composer package, so you don't write them again.
This is built in public and by extraction, not rewrite. Each piece is pulled from battle-tested production code, modernized, hardened, covered with Pest, reviewed, and only then tagged. The README tracks what is actually in the package, not what is planned. See the roadmap for what's coming.
Tech stack: Laravel 12 / 13, PHP 8.2+, Inertia 2 / 3, Vue 3, Tailwind CSS 4, Ziggy, Pest. Authentication builds on Laravel Fortify and Socialite; the activity log builds on spatie/laravel-activitylog; the media library builds on intervention/image and laravolt/avatar…
Top comments (0)