I am building LaraFoundry, a reusable Laravel SaaS core, in public. It is extracted from a CRM that already runs in production, and I ship it phase by phase. This post is phase 5.1, which bundled three service modules: a settings store, a profile hub, and a database-backed email template editor. The email editor is the one with the interesting security story, so it gets most of this article.
What shipped in phase 5.1
Three pieces, each its own sub-phase with its own review pass:
- Settings: one generic key-value store with three scopes (app, company, user), driven by a config registry.
- Profile hub: a single tabbed page for name and email, password, two-factor, PIN, sessions, avatar and UI preferences.
- Email templates: a super-admin editor for the subject and HTML body of the core emails, per language, stored in the database.
The email template editor, and why rendering is the scary part
The feature itself is mundane: let an operator change the wording of the verification email, the password reset, the welcome message and the company invitation, without a deploy, in each supported language. Store it in a table, edit it in the admin console.
The danger is in how you render it. The lazy version is to push the stored string through Blade or some expression engine so {{ $user->name }} just works. The moment you do that, an admin-authored string becomes executable. Anyone with an admin session, or anyone who steals one, can type their way to remote code execution. Server-side template injection is exactly this mistake.
So the renderer does not use Blade and does not use eval. It is a literal single pass over {{name}} tokens and nothing else:
public function render(string $template, array $data, bool $keepUnknown = false): string
{
return (string) preg_replace_callback(
'/\{\{\s*(\w+)\s*\}\}/',
function (array $matches) use ($data, $keepUnknown) {
$key = $matches[1];
if (array_key_exists($key, $data)) {
return (string) $data[$key];
}
return $keepUnknown ? $matches[0] : '';
},
$template,
);
}
A few properties fall out of this that matter:
The substitution is a single pass. The replacement value is never re-scanned, so if a piece of data happens to contain {{something}}, it cannot trigger a second round of substitution. No recursive expansion games.
The stored string never reaches an expression engine. There is no compile step, no eval, no Blade. A template in the database is structurally incapable of executing code, by construction, not by a blacklist I have to keep patching.
Unknown tokens are emptied by default, so a recipient never sees a raw {{token}} leaking into their inbox. The preview path passes keepUnknown so an operator can still see what is wired up.
That renderer is the primary safety boundary. Everything else is defense in depth:
Strict variable whitelist. Each template declares the variables it is allowed to use. On save, every {{variable}} referenced by the subject and body is checked against that whitelist across all languages. Reference something undeclared and you get a 422, not a silent surprise at send time.
HTML sanitizing. The body is run through HTMLPurifier with an email-friendly allowlist (tables, inline styles, the things email actually needs) on write and on preview. Scripts, event handlers and javascript: URLs are dropped. This is built on the purifier directly rather than a framework wrapper, so the core does not get dragged along by a wrapper package's major releases.
Sandboxed preview. The live preview renders inside an iframe with sandbox and scripts off, so even the preview surface is a second independent barrier, not the only one.
The threat model is honest about who the actor is. The super-admin is a trusted operator, not a hostile end user. The renderer that cannot execute code is the real defense; the purifier and sandbox exist for the residual case of a compromised operator account or an honest paste of broken markup. I wrote the threat model down before building so the layers had a reason to exist instead of being security theatre.
A generic settings store with three scopes
The second piece is a single key-value store that serves three scopes: platform (app), company, and user. Rather than a table per concern, there is one table and one config registry that is the single source of truth:
'settings' => [
'support_email' => ['scope' => 'app', 'type' => 'string', 'public' => true],
'signups_enabled' => ['scope' => 'app', 'type' => 'boolean', 'public' => true],
'timezone' => ['scope' => 'company', 'type' => 'string', 'validation' => ['timezone']],
'email_notifications' => ['scope' => 'user', 'type' => 'boolean'],
],
The registry is fail-closed: only declared keys can be read or written, so an arbitrary key never reaches the table, and every value is cast and validated against its declared type and rule before it lands. App settings are edited by the super-admin. Company settings are gated by RBAC permissions and always scoped to the active company, resolved on the server, never taken from the request. User settings are implicitly the caller's own, so there is no cross-user write.
Keys can also be marked public, and the host surfaces those to the frontend, which is how something like "are sign-ups open" reaches a guest page without exposing anything private.
The profile hub
The third piece pulls every self-service account screen into one tabbed page: profile fields, password, two-factor, PIN, active sessions, avatar and appearance.
The part I care about most is the boring hygiene. Changing your email asks for your current password, resets email verification, and revokes your other sessions. UI preferences are written through an allowlist (the donor code let any key into that column, which is the kind of thing you only notice when you go to extract it). Avatars go through the existing media service. And the account deletion and data export seams are already in place, ready for the GDPR phase that comes next.
Testing and integration
Every sub-phase landed green and went through an adversarial review pass before it merged: 667 backend tests in Pest and 151 on the frontend by the end of the phase. Then the whole thing was integrated into the host application (migrations, published pages, the new dependency, a frontend build) and tested again end to end, because "passes in the package" and "works in a real app on top of the package" are not the same claim.
Follow along
This is one phase of an ongoing build-in-public series. The core is free and open.
- GitHub: https://github.com/dmitryisaenko/larafoundry (star or follow the build)
- Project: https://larafoundry.com
Top comments (0)