There's a particular kind of code that never makes it into a tutorial: the stuff that has been running in production for years, quietly working, written by a younger version of yourself who was in a hurry. It works. It's also a little embarrassing to look at.
I'm extracting that code from Kohana.io - a live CRM/ERP - into LaraFoundry, an open-source SaaS core for Laravel distributed as a Composer package. Not a rewrite from scratch: an extract-and-modernize. The premise is that battle-tested code is worth more than clean code, but only if you actually re-read it on the way out.
This post is the first real code milestone: v0.1.0. The backend primitives and the frontend base. It's also the post where the extraction caught two genuine bugs that had been sitting in production. So this is less "look at my framework" and more "here's what happens when you move old code into the light."
Why extract instead of rewrite
The boring answer is risk. A rewrite throws away every edge case the original code learned the hard way - the weird locale code a real browser sent, the filter parameter someone tried to abuse, the off-by-one in pagination. Extraction keeps those lessons. The cost is that you inherit the original's mistakes too, so the extraction only pays off if it comes with a review gate.
So the workflow for every piece is fixed:
extract from legacy -> modernize + harden -> Pest tests
-> /security-review + /code-review -> tag -> integrate into host app
The package ships nothing that hasn't been through that pipe. Let's walk the milestone.
What's in v0.1.0
Fourteen PHP classes and a small Inertia/Vue frontend. No auth module yet, no multi-tenancy - those are later phases. This release is the primitives everything else stands on:
| Area | What |
|---|---|
| Locale | A single SetLocale middleware (resolution chain), a ValidLocale rule, an Inertia translation bridge |
| Query filters | A reflection-based Filter base + Filterable trait |
| Middleware | Email-verification gate, IP allow-list, intended-URL capture, appearance |
| Frontend | vue-i18n bootstrap, a form UI kit, flash toasts, a paginator, a base layout |
| Tests | 39 Pest tests |
The interesting part isn't the list. It's what changed between the legacy version and the extracted one.
Locale detection: the rewrite that paid for itself
The legacy SetLocale was actually two near-identical middleware classes - one for guests, one for authenticated users - and it did something I'm not proud of: on a guest's first request it made a synchronous HTTP call to ip-api.com to geolocate them, on the hot path, before rendering anything.
Two of those decisions were wrong, and extracting the code is what forced me to admit it.
Wrong thing #1: blocking network I/O in a request-path middleware. A third-party outage becomes your outage. So in the core, geo-IP is gone from the default path - it lives behind an optional contract a host can opt into:
interface LocaleGeoResolver
{
/**
* @param array<int, string> $available
*/
public function resolve(string $ip, array $available): ?string;
}
If you don't configure it, no network call ever happens. The common case - a browser that tells you its language in a header - costs nothing.
Wrong thing #2: trusting input that reaches App::setLocale(). This one is subtle and it's the reason I now validate every single candidate. The resolved locale eventually flows into a filesystem path when the translation bridge loads lang/{locale}.json. A locale value you don't validate is a path-traversal waiting to happen. So the extracted middleware validates every source - user preference, session, cookie, header, geo, default - against an allow-list before it's ever applied:
protected function isAllowed(?string $locale, array $available): bool
{
return $locale !== null && $locale !== '' && in_array($locale, $available, true);
}
And the fallback re-validates even the configured default, because a misconfigured default shouldn't be able to slip past the allow-list either:
$resolved = $this->detect($request, $available) ?? $default;
if (! $this->isAllowed($resolved, $available)) {
$resolved = $this->isAllowed($default, $available)
? $default
: ($available[0] ?? 'en');
}
App::setLocale($resolved);
The legacy version also injected <script>window.location.reload()</script> into the response to apply a language change. That's fragile and it breaks Inertia. In the extracted version the locale takes effect on the same request - no reload, no script injection.
One more thing the extraction fixed: the legacy data layer had collected junk locale codes over the years - 'ua' (not a real code; the ISO 639-1 code for Ukrainian is uk), 'English', country slugs. The single allow-list plus a tiny validation rule means that can't happen again:
class ValidLocale implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$available = (array) config('larafoundry.locale.available', []);
if (! is_string($value) || ! in_array($value, $available, true)) {
$fail('The selected :attribute is not a supported locale.')->translate();
}
}
}
One source of truth. Everything - URL, cookie, DB, header - is validated against the same list.
Query filters: a reflection trick with a sharp edge
The legacy app had a neat pattern for list filtering: a filter class declares one method per query parameter, and the request keys get dispatched to matching methods.
class ProductFilter extends Filter
{
public function search(string $value): void
{
$this->builder->where('name', 'like', "%{$value}%");
}
}
Product::filter(new ProductFilter($request))->paginate();
Elegant. Also a loaded gun, if you implement the dispatch naively. If you just take request keys and call $this->{$key}($value), an attacker sends ?apply=... and now they're invoking your base class's apply() method - or any other method on the object - with input they control. Mass method invocation.
Writing the Pest test for this is what made the hole obvious. The fix is to restrict callable filters to public methods physically declared on the concrete subclass - nothing inherited, nothing static:
protected function callableFilters(): array
{
$reflection = new \ReflectionClass(static::class);
$names = [];
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
// Only methods declared on the subclass, not inherited from Filter.
if ($method->getDeclaringClass()->getName() === self::class) {
continue;
}
if ($method->isStatic() || $method->isConstructor()) {
continue;
}
$names[] = $method->getName();
}
return $names;
}
And the test that locks it down:
it('never invokes methods inherited from the base Filter via a request key', function () {
// ?apply=... must not reach Filter::apply()
// ?callableFilters=... must not reach the helper
// Only declared subclass methods are callable.
});
This is the whole argument for the extraction-plus-review approach in one example. The pattern shipped to production years ago. Nobody exploited it. But it was only when I wrote the test for the extracted version that the gap became a red bar instead of a latent risk.
The frontend: making Inertia + Vue survive inside vendor/
Here's the problem that nearly sank the whole "core as a Composer package" idea, and the one I was most nervous about.
A normal Inertia app resolves its pages with something like import.meta.glob('./Pages/**/*.vue'). That glob is resolved by Vite at build time, relative to the host app. The moment your components live in vendor/dmitryisaenko/larafoundry/..., that resolution breaks. Frontend code is not as relocatable as PHP.
The answer turned out to be: don't try to make the package own the page-resolution mechanism at all. The package exports plain ES modules and Vue SFCs, plus one bootstrap function. Routing and page resolution stay the host's job - the core never assumes how those are wired.
import { createLaraFoundry } from '@dmitryisaenko/larafoundry';
setup({ el, App, props, plugin }) {
const app = createApp({ render: () => h(App, props) }).use(plugin);
createLaraFoundry(app, props.initialPage.props);
app.mount(el);
}
createLaraFoundry installs i18n (wired from the same Inertia shared props the backend sends) and registers the shared components. Ziggy and the Inertia plugin stay with the host. The CSS theme is a @theme block the host imports straight from vendor/:
@import 'tailwindcss';
@import '../../vendor/dmitryisaenko/larafoundry/resources/css/theme.css';
This is the boring solution and that's exactly why it works. The package stopped trying to be the application and became a library the application uses. Risk closed.
The i18n bridge mirrors the legacy ergonomics so host code migrates without edits - {{ $t('key') }} in any template with no import - but inside the package itself there's a proper useT() composable instead of a global.
The review gate, and a bug it caught
Before tagging v0.1.0 I ran the code through /security-review and /code-review, plus a cross-check of the key decisions against the Laravel 13 docs. Security came back clean. Code review surfaced a real one, in the flash-message toast component.
The bug: auto-dismissing toasts kept their dismiss timer keyed by the message slot (disappear-info), not by the message itself. So if a new "disappearing" message arrived while the previous one's timer was still pending, the stale timer would fire and close the new message early. Classic identity bug.
Before:
// timer keyed by slot - a replacement message inherits the old timer
if (isDisappearType(m.type) && !timeouts[m.key]) {
timeouts[m.key] = setTimeout(() => closeMessage(m.key), AUTO_DISMISS_MS);
}
After - the timer is tied to the message's content-derived id, and stale timers are cleared when the message list changes:
.map((m) => ({ ...m, id: `${m.slot}:${m.content}` }));
// a new message in the same slot can't inherit the previous one's timer
if (isDisappearType(m.type) && !timeouts[m.id]) {
timeouts[m.id] = setTimeout(() => closeMessage(m), AUTO_DISMISS_MS);
}
Not a security issue. Just the kind of thing that produces a "sometimes the notification vanishes too fast" bug report you can never reproduce on demand. Better to kill it before the tag than to chase it in six months.
Testing
39 Pest tests in this release. They aren't decoration - two of them are the reason bugs above are former bugs:
it('never invokes methods inherited from the base Filter via a request key', function () {
// mass-method-invocation guard
});
it('falls back to a safe locale when the configured default is invalid', function () {
// a misconfigured default must not bypass the allow-list
});
it('normalises a simple paginator without total/last_page', function () {
// paginators that lack lastPage()/total() must not throw
});
The locale and filter suites cover the cases that matter: every resolution source validated, unsupported codes rejected, base-class methods unreachable from input, empty filter values skipped, pagination payloads that don't explode on a SimplePaginator.
CI runs Pest plus Pint across PHP 8.2 / 8.3 / 8.4 on every push. The legacy repo had its test workflow disabled - renamed to *.yml_old. Turning that back on, green, is its own small satisfaction.
What I'd tell myself before starting
- Extraction is a review disguised as a refactor. The value isn't moving the code; it's that moving it forces you to re-read every line with fresh, slightly-more-paranoid eyes. Budget for the bugs you'll find.
- A library is not an application. The frontend risk evaporated the moment the package stopped trying to own page resolution and just exported modules.
- Validate at the boundary, every source. The locale allow-list isn't one check - it's the same check applied to user preference, session, cookie, header, geo, and default. One of those is the one you forgot.
- Tests are how a latent risk becomes a fixed bug. The mass-method-invocation gap was harmless in production right up until it wasn't. The Pest test is what changed its status from "probably fine" to "provably closed."
Honest scope
To be clear about what v0.1.0 is and isn't: this is the foundation layer. Auth, users, multi-tenancy, admin, billing - all later phases, none of it shipped yet. The package description names the full ambition; the tag delivers the primitives. I'd rather undersell a tag than have you composer require something that doesn't match the post.
If you're building a Laravel + Inertia + Vue SaaS and you've ever wondered what it takes to package the cross-cutting parts cleanly - this is the start of that, built in public, extracted from something real.
LaraFoundry is an open-source Laravel SaaS core, built in public and extracted from a production CRM/ERP.
- GitHub: github.com/dmitryisaenko/larafoundry
- Website: larafoundry.com
Top comments (0)