A bit of backstory, because it is the whole point of this post.
Kohana.io came first. It is a CRM I built. I pulled its foundations out into LaraFoundry, a reusable Laravel SaaS package, and now I am building Kohana.io a second time, this time on top of that package. The app the engine came from, rebuilt on the engine, before the package goes out to anyone else. If it cannot carry its own birthplace, it has no business carrying yours.
So this is the real test, and the part most write-ups skip: not the first laravel new, not the launch, but the day you wire a real product shell on your own foundation and find out whether it holds. I stood up the whole host (shell, theming, auth, profiles, account lifecycle), then spent an hour chasing a profile form that lied to me.
No secret sauce here. The actual business logic of Kohana.io (the CRM domain) is still ahead. Everything below is the scaffolding any SaaS needs, so I can be open about all of it.
The setup: engine vs host
Two repos do the work, and they share a bloodline.
LaraFoundry is the engine. It is a Composer package (dmitryisaenko/larafoundry) extracted from the original Kohana.io, with real production mileage behind that code. Auth, multi-tenancy, roles, settings, notifications, tickets, legal and GDPR, a billing seam, all live in the package now with their own Pest suite.
Kohana.io is the host: the same product, rebuilt clean. A normal Laravel 13 app that consumes the engine it once gave birth to, then adds its own skin and, eventually, its own domain back on top. Dogfooding in the most literal sense.
Locally the host pulls the engine through a Composer path repository, so I can edit the package and the app side by side:
"repositories": [
{ "type": "path", "url": "../larafoundry" }
],
"require": {
"dmitryisaenko/larafoundry": "*"
}
composer require dmitryisaenko/larafoundry, and the engine arrives with its dependencies in tow:
- Inertia 3 and Vue 3 for the frontend, no separate API client to babysit
- Tailwind CSS v4 for styling
- Laravel Fortify for the auth backend (login, registration, password reset, two-factor)
- Laravel Sanctum for token and same-origin API auth (this is what lets a future mobile app and the QR login share one guard)
- Ziggy so the Vue side can name routes
- vue-i18n for the multilanguage UI
- Pest for the tests
One command, and the host has a working auth backend, a tenancy model, a roles system and a design system. That is the whole point of the package.
Skinning, not rebuilding
Here is the part I was nervous about and turned out to be the easy part.
The package ships a real design system: Tailwind v4 design tokens, layouts, and around 60 pages already built on semantic tokens (bg-surface, text-ink, border-border, and so on). It is intentionally neutral so the whole ecosystem can reuse it.
So skinning Kohana.io did not mean rewriting pages. It meant one override layer in the host app.css: restate the same semantic tokens in the Kohana palette (cool neutral surfaces, a blue brand, Inter as the font) and add a dark layer. Tailwind v4 merges @theme blocks, so the host values win over the package defaults, and every package page recolors itself.
@theme {
--color-brand-500: #3b82f6;
--color-surface: #ffffff;
--color-ink: #1a2334;
/* ...the rest of the palette... */
}
/* dark layer, toggled by html.dark */
html.dark {
--color-surface: #121a2b;
--color-ink: #e7ecf6;
}
On top of that I built the host shell by hand, because the shell is where a product gets its personality: a full-width top bar, a collapsible sidebar rail, an account pullout, and a small overlay system for the mobile drawer, search and language menus.
Auth, almost for free
Because Fortify and Sanctum came with the package, the auth surface was mostly wiring and skinning, not building from zero. What Kohana.io now has:
- Email and password registration and login
- Google sign-in (OAuth)
- QR cross-device login: the login card flips to a QR panel, you scan it with an already signed-in device, and the browser session is approved
- Two-factor authentication (TOTP) through Fortify, with recovery codes
- A session PIN lock: an authenticated session can lock itself, and unlocking uses an iPhone-style keypad
The PIN screen was the one place I let myself fuss over the details, because a lock screen is something you touch a lot. A row of dots, a round number pad, a digit that flashes then collapses into a dot, a shake plus a short haptic buzz on the wrong code, real hover states on a pointer and pressed states on touch.
Theming that follows the system, then obeys you
Default behavior: the app follows your OS or browser theme. No cookie set means it reads prefers-color-scheme before first paint (an inline script in the Blade root sets the class so there is no flash of the wrong theme), and it keeps following the OS live, so flipping your system to dark flips the page without a reload.
The moment you press the theme toggle, that becomes your choice. For a signed-in user it is stored in their settings. For a guest it rides a small appearance cookie (light or dark), re-stamped on every visit so it effectively never lapses. Once you have chosen, the OS no longer overrides you.
This runs everywhere a guest can land: the marketing landing, the auth screens, the lock screen. The dark sections of the landing lighten in light mode and keep their branded navy in dark mode.
Profile and account lifecycle
The profile hub is the kind of thing that is easy to demo and easy to get subtly wrong. Changing your email asks for your current password, resets email verification, and revokes your other sessions. There is an avatar, an appearance tab, active sessions, and a danger zone that does the GDPR work: export your data, or delete your account.
Account lifecycle is enforced server-side by the package middleware, not by hiding buttons. A blocked or deleted account is logged out on its next request. A super-admin is kept inside the operator console. If a Terms page is published, a re-accept gate makes sure consent is current. All of it is no-op for a normal signed-in user and never trusts the client.
The bug that fooled me
Now the part I actually want to remember.
I opened the profile form, changed my last name, my phone, my country, my date of birth, hit save. Green toast: saved. Refreshed. Everything except name and email was gone. The toast was not lying about the request succeeding. The data really was being dropped.
Database row: only name and email had changed. So the request reached the server, validation passed, and most fields quietly evaporated.
The cause was a shadow. The Laravel starter kit that seeded the host had scaffolded its own App\Actions\Fortify\UpdateUserProfileInformation, a stub that does forceFill(['name', 'email']) and nothing else. And in the host's FortifyServiceProvider, a single line bound it over the package:
// host app, FortifyServiceProvider::boot()
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
That call is a singleton rebind. It runs after the package registers its own full action, so it wins. The package action that knows how to persist every profile field never ran. Fortify dutifully called the stub, the stub saved two fields, everyone went home happy except the database.
The fix was deletion, not addition: drop the override line, delete the dead scaffold file, and let the package contract resolve. Then a regression test so it can never come back quietly:
it('persists every profile field', function () {
$user = User::factory()->create();
$this->actingAs($user)->put('/user/profile-information', [
'name' => $user->name,
'email' => $user->email,
'lastname' => 'Doe',
'phone' => '+10000000000',
'country' => 'US',
'birth_date' => '1990-01-01',
])->assertSessionHasNoErrors();
expect($user->fresh()->lastname)->toBe('Doe');
});
The lesson generalizes. When a host extends a package, the danger is rarely a conflict you can see. It is scaffolded code, generated months ago by a starter kit, silently overriding a contract the package expected to own. Now I grep for ...Using( rebinds in a host before I trust anything.
Proof: the tests
Every change runs against Pest. The host has its own integration suite (167 tests at the time of writing) that exercises the package through the real app: auth flows, tenancy, profile, tickets, the waitlist, the lifecycle middleware. The engine itself ships well over 700 tests. CI runs both, and the frontend has to build clean before anything merges.
That regression test above is now one of the 167. It went red the moment I reproduced the bug and green the moment I deleted the override, which is exactly the feedback loop you want when a framework is quietly working against you.
What is next
The scaffolding is up: the app the engine came from, rebuilt on the engine, with auth, theming, profiles and account lifecycle all wired and tested. That is the part I most wanted to prove, because if the package can carry its own birthplace it can carry someone else's app too. None of it is the product though. The product is the CRM domain that goes on top, and that is what comes next.
If you like watching a SaaS get rebuilt on a foundation you can install yourself, this is a good time to follow along.
Follow along
- LaraFoundry on GitHub: https://github.com/dmitryisaenko/larafoundry
- Kohana.io public repo: https://github.com/dmitryisaenko/kohana_public
- The engine and its story: https://larafoundry.com
- The product being built on it: https://kohana.io
Top comments (0)