<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dmitry Isaenko</title>
    <description>The latest articles on DEV Community by Dmitry Isaenko (@d_isaenko_dev).</description>
    <link>https://dev.to/d_isaenko_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3698265%2F374d1b3f-447c-4153-bf1a-7b7bd53e8f6a.jpg</url>
      <title>DEV Community: Dmitry Isaenko</title>
      <link>https://dev.to/d_isaenko_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/d_isaenko_dev"/>
    <language>en</language>
    <item>
      <title>Meet Krokq: a light, adaptable task tracker and chat with a built-in AI assistant</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Mon, 13 Jul 2026 15:23:04 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/meet-krokq-a-light-adaptable-task-tracker-and-chat-under-one-shell-adf</link>
      <guid>https://dev.to/d_isaenko_dev/meet-krokq-a-light-adaptable-task-tracker-and-chat-under-one-shell-adf</guid>
      <description>&lt;h2&gt;
  
  
  The pain this grew out of
&lt;/h2&gt;

&lt;p&gt;I help one client with blog support, and their team was working across four different messengers at once. Some people in one, some in another, part of them on a phone only. There was no single place to track anything. Every subproject landed in one shared chat, and it turned into a mess: tasks got lost, agreements were forgotten, and results were written down nowhere.&lt;/p&gt;

&lt;p&gt;The big systems are supposed to fix this. But Odoo, monday, ClickUp and the heavy ERPs are too much for a small team, packed with features nobody there will ever touch. By the time you finish setting one up, any wish to actually use it is gone.&lt;/p&gt;

&lt;p&gt;I wanted something light that fit this exact problem. That is how Krokq started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Krokq is
&lt;/h2&gt;

&lt;p&gt;Krokq (krok means step) is a light and completely free task tracker and chat under one shell, with a built-in AI assistant.&lt;/p&gt;

&lt;p&gt;The chat is built to feel like the messengers people already use, WhatsApp, Telegram, Viber. That matters more than it sounds: nobody has to learn a new tool, it is familiar from the first minute, and the barrier to entry is almost zero. It takes the parts that actually get used: messaging, drafts, comments, voice notes, drag and drop files, reactions. But the main thing is moving freely between the two modes, Chat and Tasks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Turn a chat message into a Task in one click.&lt;/li&gt;
&lt;li&gt;Jump from a Task straight into a private chat with that person.&lt;/li&gt;
&lt;li&gt;Or keep the discussion right inside the Task, whichever fits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Tasks side
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Create a task by voice with the built-in AI assistant (more on that below).&lt;/li&gt;
&lt;li&gt;A real workflow: New, In progress, Rework, In review, Done, plus Trash and Archive.&lt;/li&gt;
&lt;li&gt;Custom task types to match how a team works.&lt;/li&gt;
&lt;li&gt;A board with columns, with urgent items standing out.&lt;/li&gt;
&lt;li&gt;Threaded comments under a task, with reactions, editing and deleting.&lt;/li&gt;
&lt;li&gt;Attachments on a task, uploaded or pulled from a connected source.&lt;/li&gt;
&lt;li&gt;Search across tasks.&lt;/li&gt;
&lt;li&gt;Status based permissions: an assignee goes read only once a task is accepted or archived, for example.&lt;/li&gt;
&lt;li&gt;Assigning an owner, accepting work, or sending it back for rework.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Chat side
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Private conversations with colleagues.&lt;/li&gt;
&lt;li&gt;Drafts, editing, deleting and forwarding messages.&lt;/li&gt;
&lt;li&gt;Reactions on messages.&lt;/li&gt;
&lt;li&gt;Voice messages with a waveform.&lt;/li&gt;
&lt;li&gt;Attachments: drag files into a thread and back out, paste an image from the clipboard.&lt;/li&gt;
&lt;li&gt;Presence indicators and read receipts.&lt;/li&gt;
&lt;li&gt;Quoting and replying to a message.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The AI assistant
&lt;/h2&gt;

&lt;p&gt;There is a built-in AI assistant for creating tasks by voice. You tap the button, say what you need in plain words, something like "new task for Sveta to check the banners, urgent, by Friday", and it opens a ready to save task form with the title, the assignee, urgency and the due date already filled in. You look it over, fix anything, and save.&lt;/p&gt;

&lt;p&gt;It works in any of the 10 languages the app speaks, and you can pin the language you dictate in from your profile when it differs from the interface language. Under the hood the clip is transcribed and turned into a structured intent by Gemini, then mapped onto real people and task types from your company. For now it does one thing, creating a task, and it does it well. More intents are next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adaptable through connections
&lt;/h2&gt;

&lt;p&gt;This is the part I am most happy with, and the reason Krokq stays light without being limited. Krokq connects to the services a team already uses, and their content shows up right inside the app as a built in source.&lt;/p&gt;

&lt;p&gt;Two connections are ready so far, both to WordPress:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gallery: browse and search a connected site's media library, read only, through the app backend.&lt;/li&gt;
&lt;li&gt;Posts: the list of the site's posts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything goes through the backend over the WP REST API and Application Passwords, you can connect several sites at once, and access is set per company. The useful bit: right from there you can build a Task or drop a message into Chat, without exporting or copying anything by hand.&lt;/p&gt;

&lt;p&gt;Under the hood each connection is a small descriptor class. Here is the WordPress Gallery one, trimmed to the essentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A Krokq connection is just a small descriptor. This one pulls a&lt;/span&gt;
&lt;span class="c1"&gt;// WordPress media library into the app, read-only, through the backend.&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WpGalleryPlugin&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractPlugin&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'wp-gallery'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'WordPress Gallery'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;multiInstance&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// connect several sites&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a new one for another service is the same kind of descriptor, so the list keeps growing around real requests. That is what adaptable means here: the tool shapes itself to the work, not the other way around.&lt;/p&gt;

&lt;h2&gt;
  
  
  An app, not just a website
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Installs as an app through Chrome on Android and Safari on iOS, and on desktop too. If the system allows standalone, Krokq offers to install itself.&lt;/li&gt;
&lt;li&gt;Web Push notifications, with an unread counter right on the icon.&lt;/li&gt;
&lt;li&gt;Share files into the app: the system lists Krokq among the share targets for most file types.&lt;/li&gt;
&lt;li&gt;10 languages, more as they are needed.&lt;/li&gt;
&lt;li&gt;No mobile app yet, but the whole base for it is in place: an API plus Sanctum. So it is a matter of time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sign in and demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sign in with a login and password or through Google OAuth. Signing in on another device by QR code is next on my list.&lt;/li&gt;
&lt;li&gt;Right after signing in you can fill the app with demo data in one click (the vertical is retail, in any of the 10 languages). The demo lives for 24 hours and then deletes itself, so the sandbox is always clean.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this runs on
&lt;/h2&gt;

&lt;p&gt;Krokq runs on my own engine package, LaraFoundry, a core for spinning up SaaS projects fast: companies, users, roles, authentication. Krokq is the third application on that engine, and the Krokq side itself took me about three days. The engine already runs in production on other apps, and I keep polishing it in parallel.&lt;/p&gt;

&lt;p&gt;The domain is covered by tests (Pest and PHPUnit), with the usual CI on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;Bigger things in progress: bulk actions on messages and attachments, data export, an admin layer for oversight, and more service connections. I will show them as they land.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://krokq.com" rel="noopener noreferrer"&gt;Krokq: krokq.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/dmitryisaenko/krokq_public" rel="noopener noreferrer"&gt;Public repo: github.com/dmitryisaenko/krokq_public&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a light tracker plus chat is something your small team keeps improvising in group messengers, give Krokq a try and tell me what is missing.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>buildinpublic</category>
      <category>sass</category>
    </item>
    <item>
      <title>I extracted a Laravel SaaS engine from my own app, then rebuilt the app on top of it. Here is what stood up, and the bug that fooled me</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Tue, 16 Jun 2026 14:03:03 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/i-extracted-a-laravel-saas-engine-from-my-own-app-then-rebuilt-the-app-on-top-of-it-here-is-what-1hb0</link>
      <guid>https://dev.to/d_isaenko_dev/i-extracted-a-laravel-saas-engine-from-my-own-app-then-rebuilt-the-app-on-top-of-it-here-is-what-1hb0</guid>
      <description>&lt;p&gt;A bit of backstory, because it is the whole point of this post.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So this is the real test, and the part most write-ups skip: not the first &lt;code&gt;laravel new&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: engine vs host
&lt;/h2&gt;

&lt;p&gt;Two repos do the work, and they share a bloodline.&lt;/p&gt;

&lt;p&gt;LaraFoundry is the engine. It is a Composer package (&lt;code&gt;dmitryisaenko/larafoundry&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Locally the host pulls the engine through a Composer path repository, so I can edit the package and the app side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"repositories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"../larafoundry"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"require"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dmitryisaenko/larafoundry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;composer require dmitryisaenko/larafoundry&lt;/code&gt;, and the engine arrives with its dependencies in tow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inertia 3 and Vue 3 for the frontend, no separate API client to babysit&lt;/li&gt;
&lt;li&gt;Tailwind CSS v4 for styling&lt;/li&gt;
&lt;li&gt;Laravel Fortify for the auth backend (login, registration, password reset, two-factor)&lt;/li&gt;
&lt;li&gt;Laravel Sanctum for token and same-origin API auth (this is what lets a future mobile app and the QR login share one guard)&lt;/li&gt;
&lt;li&gt;Ziggy so the Vue side can name routes&lt;/li&gt;
&lt;li&gt;vue-i18n for the multilanguage UI&lt;/li&gt;
&lt;li&gt;Pest for the tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skinning, not rebuilding
&lt;/h2&gt;

&lt;p&gt;Here is the part I was nervous about and turned out to be the easy part.&lt;/p&gt;

&lt;p&gt;The package ships a real design system: Tailwind v4 design tokens, layouts, and around 60 pages already built on semantic tokens (&lt;code&gt;bg-surface&lt;/code&gt;, &lt;code&gt;text-ink&lt;/code&gt;, &lt;code&gt;border-border&lt;/code&gt;, and so on). It is intentionally neutral so the whole ecosystem can reuse it.&lt;/p&gt;

&lt;p&gt;So skinning Kohana.io did not mean rewriting pages. It meant one override layer in the host &lt;code&gt;app.css&lt;/code&gt;: 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 &lt;code&gt;@theme&lt;/code&gt; blocks, so the host values win over the package defaults, and every package page recolors itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--color-brand-500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3b82f6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a2334&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c"&gt;/* ...the rest of the palette... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* dark layer, toggled by html.dark */&lt;/span&gt;
&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#121a2b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e7ecf6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth, almost for free
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email and password registration and login&lt;/li&gt;
&lt;li&gt;Google sign-in (OAuth)&lt;/li&gt;
&lt;li&gt;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&lt;/li&gt;
&lt;li&gt;Two-factor authentication (TOTP) through Fortify, with recovery codes&lt;/li&gt;
&lt;li&gt;A session PIN lock: an authenticated session can lock itself, and unlocking uses an iPhone-style keypad&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theming that follows the system, then obeys you
&lt;/h2&gt;

&lt;p&gt;Default behavior: the app follows your OS or browser theme. No cookie set means it reads &lt;code&gt;prefers-color-scheme&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;appearance&lt;/code&gt; cookie (light or dark), re-stamped on every visit so it effectively never lapses. Once you have chosen, the OS no longer overrides you.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profile and account lifecycle
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that fooled me
&lt;/h2&gt;

&lt;p&gt;Now the part I actually want to remember.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Database row: only &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;email&lt;/code&gt; had changed. So the request reached the server, validation passed, and most fields quietly evaporated.&lt;/p&gt;

&lt;p&gt;The cause was a shadow. The Laravel starter kit that seeded the host had scaffolded its own &lt;code&gt;App\Actions\Fortify\UpdateUserProfileInformation&lt;/code&gt;, a stub that does &lt;code&gt;forceFill(['name', 'email'])&lt;/code&gt; and nothing else. And in the host's &lt;code&gt;FortifyServiceProvider&lt;/code&gt;, a single line bound it over the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// host app, FortifyServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;Fortify&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;updateUserProfileInformationUsing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UpdateUserProfileInformation&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'persists every profile field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/user/profile-information'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'lastname'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Doe'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'phone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'+10000000000'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'country'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'US'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'birth_date'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1990-01-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSessionHasNoErrors&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;lastname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Doe'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;...Using(&lt;/code&gt; rebinds in a host before I trust anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof: the tests
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you like watching a SaaS get rebuilt on a foundation you can install yourself, this is a good time to follow along.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;LaraFoundry on GitHub: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;https://github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Kohana.io public repo: &lt;a href="https://github.com/dmitryisaenko/kohana_public" rel="noopener noreferrer"&gt;https://github.com/dmitryisaenko/kohana_public&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The engine and its story: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;https://larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The product being built on it: &lt;a href="https://kohana.io" rel="noopener noreferrer"&gt;https://kohana.io&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>inertiajs</category>
      <category>vue</category>
    </item>
    <item>
      <title>Building a SaaS engine in public: an affiliate program that isn't one hardcoded scheme</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:49:48 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-an-affiliate-program-that-isnt-one-hardcoded-scheme-ok</link>
      <guid>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-an-affiliate-program-that-isnt-one-hardcoded-scheme-ok</guid>
      <description>&lt;p&gt;For most of this series I have been shipping the parts of a SaaS that you can see: auth, multi-tenancy, an admin console, billing. The affiliate program is the part I deliberately left for last, because it is the one most likely to get built as a special case and regretted later. This post is about finishing it without that regret.&lt;/p&gt;

&lt;p&gt;LaraFoundry is a reusable Laravel SaaS core I am extracting in public from a CRM that already runs in production, one module at a time. The core is free and stays free for everything except money. The day a business wants to charge its own customers, that is the paid billing add-on. An affiliate program is squarely on the paid side: it is a way to grow paid revenue, so it ships inside that add-on, on top of the billing engine I wrote about a few posts back.&lt;/p&gt;

&lt;p&gt;Here is what "done" means concretely. A partner identity with a unique referral code. A capture link that attributes a new tenant to the partner who sent them. Commission that accrues when a referred tenant pays, on rules you choose. A clawback when that payment is refunded or fails. A super-admin payout console with per-currency totals and a CSV export. And a self-serve dashboard where a partner sees their own referrals and balance. All of it under 314 Pest tests.&lt;/p&gt;

&lt;p&gt;Two decisions shaped every line, and they are the whole point of the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision one: an engine, not a scheme
&lt;/h2&gt;

&lt;p&gt;The easy way to build an affiliate program is to encode the one you want. Twenty percent, recurring, paid on every invoice. It works, you ship in a day, and you have just fenced every host that uses your core into your commission policy. A reusable core cannot do that. So instead of a scheme I built an engine, configured along three axes that do not know about each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'affiliate'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'enabled'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'eligibility'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// auto | self_serve | admin&lt;/span&gt;
    &lt;span class="s1"&gt;'commission'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'trigger'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'recurring'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// first_payment | recurring | lifetime&lt;/span&gt;
        &lt;span class="s1"&gt;'window_months'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'basis'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'per_plan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// per_plan | percent | fixed&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eligibility answers who becomes a partner. &lt;code&gt;auto&lt;/code&gt; mints a code for every user so anyone can share a link. &lt;code&gt;self_serve&lt;/code&gt; lets users opt in but holds them pending an admin approval. &lt;code&gt;admin&lt;/code&gt; means partners are invited only. Trigger answers which payments pay out. &lt;code&gt;first_payment&lt;/code&gt; is a one-time bounty per referral. &lt;code&gt;recurring&lt;/code&gt; keeps paying for a window, twelve months by default, then stops. &lt;code&gt;lifetime&lt;/code&gt; never stops. Basis answers how the amount is figured. &lt;code&gt;per_plan&lt;/code&gt; reads a commission amount from the plan dictionary, &lt;code&gt;percent&lt;/code&gt; takes a share of the net payment, &lt;code&gt;fixed&lt;/code&gt; is a flat fee. A per-partner override can raise or lower the percent rate for a specific affiliate.&lt;/p&gt;

&lt;p&gt;Because the three axes are orthogonal, any combination is valid and the engine does not branch into a tangle of special cases. The trigger decides whether a given payment is eligible at all; the basis, independently, decides the amount once it is. The default is the sensible one (a code for everyone, recurring for a year, priced per plan) but it is a default, not a law.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision two: zero host code
&lt;/h2&gt;

&lt;p&gt;The part I am happiest with is that turning a partner program on does not add a single line of PHP to the host application. It rides two events that already existed before the affiliate program did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Free core, public: fired whenever a tenant is created.&lt;/span&gt;
&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CompanyCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$company&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$owner&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Paid add-on, fired whenever a company payment is processed.&lt;/span&gt;
&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CompanyPaymentProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payment&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CompanyCreated&lt;/code&gt; is part of the free core. It fires on every signup regardless of billing or affiliates, because the core needs it for its own bookkeeping. &lt;code&gt;CompanyPaymentProcessed&lt;/code&gt; is the billing add-on's own event, fired by the gateway webhook listener when a charge settles. Neither was invented for the affiliate program. The affiliate program simply subscribes to them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CompanyCreated&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AttributeReferral&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CompanyPaymentProcessed&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AccrueAffiliateCommission&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AttributeReferral&lt;/code&gt; reads the referral cookie set by the capture link and locks the new company to a partner. &lt;code&gt;AccrueAffiliateCommission&lt;/code&gt; runs the trigger and basis logic and writes a commission row. Both listeners, and the engine behind them, are the proprietary part of the add-on, so I am describing them rather than dumping them. The shape is what matters here: the add-on registers these listeners from its own service provider, gated behind the &lt;code&gt;affiliate.enabled&lt;/code&gt; flag. A host turns the feature on in config, publishes the two Inertia pages, and is finished. There is no controller to write, no event to fire by hand, no model to touch. The seam was already in the core; the add-on plugs into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribution is lock-once, and not an oracle
&lt;/h2&gt;

&lt;p&gt;A referral link is &lt;code&gt;/r/{code}&lt;/code&gt;. Hitting it drops an encrypted cookie and redirects. The redirect is uniform: it goes to the same place whether the code is real or not, so the endpoint never becomes an oracle you can poke to enumerate valid codes. When that visitor later creates a company, &lt;code&gt;AttributeReferral&lt;/code&gt; fires.&lt;/p&gt;

&lt;p&gt;Attribution locks once. The first partner to refer a company keeps it; a later link cannot steal an existing tenant, because the referral row is keyed unique on the referred company. Self-referral is blocked, so a partner cannot sign up under their own code. And the partner gets exactly one bounty per referral under the &lt;code&gt;first_payment&lt;/code&gt; trigger, tracked so an out-of-order webhook cannot pay it twice. That last one was a real bug an adversarial review pass caught before it shipped: the idempotency key was per payment, not per referral, which under a replayed or out-of-order webhook could have accrued two first-payment bounties. The fix was to make "first payment" mean "this referral has no live commission yet," and a regression test now guards it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money stays honest
&lt;/h2&gt;

&lt;p&gt;Commissions are stored per currency, in integer minor units, with no converter anywhere. This is the same rule the rest of the billing engine follows. A partner who refers customers paying in euros, zlotys and dollars accrues three separate balances, and the console reports three separate totals. There is no FX rounding turning real money into an approximation of a dollar figure.&lt;/p&gt;

&lt;p&gt;Payout is manual on purpose, and I want to be clear that this is a feature, not a gap. The add-on has no money-out payment provider, and it does not pretend to have one. What it gives the operator is the truth: a report of who is owed what, per currency, with a state machine they drive by hand. A commission starts &lt;code&gt;pending&lt;/code&gt; when it accrues. The operator approves a batch (&lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;approved&lt;/code&gt;), then marks them paid once the money has actually left, attaching a payout reference (&lt;code&gt;approved&lt;/code&gt; to &lt;code&gt;paid&lt;/code&gt;). A commission can be voided manually for a refund the automatic clawback could not catch. Speaking of which: when a payment is refunded or fails, the matching commission for that same invoice is clawed back automatically, unless it has already been paid out, in which case it is left alone for a human to reconcile.&lt;/p&gt;

&lt;p&gt;The console exports the current filtered report as a CSV, and that CSV neutralises spreadsheet formula injection: any cell whose text starts with one of the dangerous characters is prefixed so a spreadsheet treats it as literal text, not a formula. A payout reference an operator typed should never execute in Excel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two surfaces
&lt;/h2&gt;

&lt;p&gt;There are two UI surfaces, both Inertia and Vue pages the host publishes, the same delivery pattern the rest of the core's frontend uses, so the add-on does not drag in a second build pipeline.&lt;/p&gt;

&lt;p&gt;The super-admin gets the payout console: the commission report with per-currency totals by status, the bulk approve / mark-paid / void actions, and the CSV export. The partner gets a self-serve dashboard scoped strictly to their own user id: their code, their referrals, and their balance by currency and status. The dashboard never leaks another partner's numbers, which the tests assert directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof, because every release claims one
&lt;/h2&gt;

&lt;p&gt;314 Pest tests in the add-on, Pint clean, and a two-agent adversarial review (one for security, one for correctness) before this could be called done. The security pass came back with no high findings: the partner dashboard scope is airtight, the console is gated three ways (route, policy, form request), the payout state machine is safe against id tampering, and attribution is self-referral-blocked and oracle-free. The correctness pass found the out-of-order double-bounty I described above, plus a quieter one: a percent rate provided through an environment variable arrives as a string, and the coercion had been rejecting numeric strings and silently falling to zero percent, which would have paid nobody. Both are fixed with regression tests. A payout you cannot trust is worse than no program at all, and the failure modes here are the quiet kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honesty section
&lt;/h2&gt;

&lt;p&gt;Every post in this series gets one. This is the new code, not the proven-by-years code. The core underneath the affiliate program comes out of a CRM that runs in production, but the affiliate engine itself is greenfield, written for this milestone, and proven by tests rather than by time. Its accrual rides on &lt;code&gt;CompanyPaymentProcessed&lt;/code&gt;, which is driven by the billing gateways I have built and tested but not yet run against a live merchant account end to end. So I am not going to tell you commissions have been earned and paid in production, because they have not. They have been earned and paid across 314 tests.&lt;/p&gt;

&lt;p&gt;And it is a lean engine, not a full affiliate platform. There is no automated money-out, no fraud scoring, no tiered partner ladders. Those are deliberate non-goals for a first version. What is here is the spine: attribution, a configurable accrual engine, clawback, a payout console and a partner dashboard, drawn so a host can grow it without forking it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread, again
&lt;/h2&gt;

&lt;p&gt;Every phase in this series lands on the same shape. The interesting engineering is fine, but the real lesson is always a default that was right for one app and wrong for a reusable one. For the affiliate program it was the commission scheme itself. The CRM I extracted from would only ever have needed one policy, because it serves one business, and hardcoding twenty-percent-recurring would have been completely reasonable there. In a reusable core it would have quietly become everyone's policy. The work was not building referral tracking. It was refusing to bake in one company's idea of what an affiliate program owes, and wiring the whole thing so a host turns it on without touching their own code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Code, versioned and Pest-tested before each tag: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The project and roadmap: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>saas</category>
      <category>opensource</category>
    </item>
    <item>
      <title>GDPR as a seam: when the right to access and the right to be forgotten are the same shape</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Wed, 10 Jun 2026 09:38:10 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/gdpr-as-a-seam-when-the-right-to-access-and-the-right-to-be-forgotten-are-the-same-shape-j5g</link>
      <guid>https://dev.to/d_isaenko_dev/gdpr-as-a-seam-when-the-right-to-access-and-the-right-to-be-forgotten-are-the-same-shape-j5g</guid>
      <description>&lt;p&gt;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.3, the legal and GDPR layer. It bundled editable legal pages, cookie consent, a Terms gate, personal-data export, and account erasure.&lt;/p&gt;

&lt;p&gt;GDPR work is usually the least loved part of a SaaS. It lands at the end, as a checkbox, as something you bolt on once a customer in the EU asks. I wanted it to be a seam from the start instead, and building it that way surfaced something I did not expect: the right to access and the right to be forgotten are the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped in phase 5.3
&lt;/h2&gt;

&lt;p&gt;Five pieces, each with its own review pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Legal pages: a super-admin editor for Terms, Privacy and Cookie policy, stored in the database, with versioning and a public &lt;code&gt;/legal/{slug}&lt;/code&gt; route.&lt;/li&gt;
&lt;li&gt;Cookie consent: a banner that is off by default, plus the plumbing to record a decision for guests and authenticated users.&lt;/li&gt;
&lt;li&gt;Terms gate: middleware that asks a user to re-accept when the published Terms version changes.&lt;/li&gt;
&lt;li&gt;Data export: a synchronous JSON download of everything the app holds about a user.&lt;/li&gt;
&lt;li&gt;Account erasure: the right to be forgotten, as a grace-period soft-delete with an irreversible anonymise at the end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The export and erasure pair is the interesting one, so it gets most of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Export and erasure are the same seam
&lt;/h2&gt;

&lt;p&gt;The same additive-provider idiom shows up all over LaraFoundry. The menu is built from menu providers. The dashboard is built from widget providers. So when it came to GDPR, I reached for the same idea twice.&lt;/p&gt;

&lt;p&gt;A module that owns user data implements one small contract to export it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ExportsUserDataProvider&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The exportable data this provider holds for the given user.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;exportFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Authenticatable&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Unique section key under which this data is filed (e.g. 'profile', 'tickets').&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the mirror contract to erase it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PurgesUserData&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Erase or anonymise the data this purger owns. Must be idempotent.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;purgeFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Authenticatable&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at them side by side. &lt;code&gt;exportFor()&lt;/code&gt; returns a slice, &lt;code&gt;purgeFor()&lt;/code&gt; destroys a slice, and everything else is identical. Two methods with the same signature pointing in opposite directions. The core ships providers for the identity it owns (profile, sessions, settings, consent). The notifications module ships a provider for the inbox. The tickets module ships one for tickets. None of them know about each other, and neither the export flow nor the erasure flow knows the full list.&lt;/p&gt;

&lt;p&gt;That symmetry is the whole point. When I add an orders module later, I register one exporter and one purger, and both GDPR rights light up for orders at once. There is no central place to update, no checklist to forget. The compliance surface grows with the app, automatically, because access and forgotten are wired the same way.&lt;/p&gt;

&lt;p&gt;A registry collects each side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;exportFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Authenticatable&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortedProviders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$sections&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;key&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exportFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$sections&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The export endpoint streams that as a JSON file, rate-limited so it cannot be hammered. The erasure registry walks the mirror set the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Erasure is a grace period, not a DELETE
&lt;/h2&gt;

&lt;p&gt;Deleting your account does not delete a row. It sets a &lt;code&gt;user_deleted_at&lt;/code&gt; timestamp, signs you out, and hides you everywhere at once. That is a reversible soft-delete, and it starts a clock. A super-admin can still restore you during the window, which is the difference between a rage-quit you regret on Tuesday and a permanent loss.&lt;/p&gt;

&lt;p&gt;The actual erasure happens later, on a daily cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_deleted_at'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_purged_at'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_deleted_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$graceDays&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;purge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forceFill&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'user_purged_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here.&lt;/p&gt;

&lt;p&gt;It anonymises, it does not hard-delete the row. The core purger blanks the name, rewrites the email to a reserved unreachable address, drops the password, the two-factor secrets, the OAuth tokens, revokes the sessions and personal access tokens, and removes the avatar file. The row survives, faceless. That keeps foreign keys intact and lets legal records that must survive (think invoices) stay attached to an anonymised identity instead of dangling.&lt;/p&gt;

&lt;p&gt;It is idempotent. A purged account is stamped with &lt;code&gt;user_purged_at&lt;/code&gt; and drops out of the query forever. A row whose purge threw half-way is left unstamped and retried on the next run, and because every purger is written to be safe to run twice, the retry just finishes the job.&lt;/p&gt;

&lt;p&gt;And each provider decides delete versus anonymise for itself. Personal data the user owns gets deleted outright. Records that have to survive get anonymised and kept. The contract does not force one policy on everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing I deliberately do not erase
&lt;/h2&gt;

&lt;p&gt;The activity log.&lt;/p&gt;

&lt;p&gt;It would feel tidy to scrub every trace of a user on erasure. It is also the wrong move. The audit trail of what happened, including the fact that an erasure ran, is exactly the evidence you need to show you honoured the request. So identity is anonymised everywhere, but the log of actions stays. Anonymise the who, keep the what. That is the line, and it was a decision, not an oversight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Legal pages, the Terms gate, and a banner that stays quiet
&lt;/h2&gt;

&lt;p&gt;The legal pages reuse work from an earlier phase. They are edited in the admin console, stored in the database per language, and rendered through the same HTML sanitizer the email template editor uses, so a stored legal page cannot smuggle a script into a public page. Each save bumps a version.&lt;/p&gt;

&lt;p&gt;That version feeds the Terms gate. It is a piece of middleware, and the important property is that it is fail-open. It only forces re-acceptance once a Terms page is actually published with a version. Until then it does nothing. You never get a redirect loop into a Terms page that does not exist yet, and a fresh install is not held hostage by a feature nobody configured. When you do publish and later bump the version, every signed-in user is asked to re-accept once, then continues exactly where they were.&lt;/p&gt;

&lt;p&gt;The cookie banner ships off. The core sets only strictly necessary cookies (session, CSRF, locale, the appearance toggle), and those do not require consent under GDPR. So there is nothing to ask about until you add analytics or marketing cookies yourself. The banner, the recorded decision and the consent state are all wired and waiting, you just flip one config flag the day you actually need it. No dark-pattern banner nagging people about cookies you never set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests, because that is the proof
&lt;/h2&gt;

&lt;p&gt;Every phase ships with its tests green and an adversarial review pass before merge. Phase 5.3 brought the suite to 743 backend tests in Pest and 164 on the frontend. Then the whole layer was wired into the host app and covered again with its own integration test: a published legal page is public, the Terms gate redirects a stale user on a real route, registration requires the checkbox only when Terms are published, the erasure cron anonymises past the grace window, and the consent state reaches the frontend.&lt;/p&gt;

&lt;p&gt;GDPR is one of those areas where "it looks done" and "it is correct" are far apart, so the tests are not decoration here. They are the difference between a checkbox and a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free core stays free
&lt;/h2&gt;

&lt;p&gt;LaraFoundry's core is open. Everything in this post is in the package, snippets and all. The plan is a free core that funds itself through a separate paid add-on, not by paywalling the basics. GDPR plumbing is about as basic as it gets, so it lives in the free core where it belongs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Star or follow the build on GitHub: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;https://github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Project and updates: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;https://larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>gdpr</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Letting admins edit email templates without handing them code execution</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Tue, 09 Jun 2026 13:38:22 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/letting-admins-edit-email-templates-without-handing-them-code-execution-cg5</link>
      <guid>https://dev.to/d_isaenko_dev/letting-admins-edit-email-templates-without-handing-them-code-execution-cg5</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped in phase 5.1
&lt;/h2&gt;

&lt;p&gt;Three pieces, each its own sub-phase with its own review pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Settings: one generic key-value store with three scopes (app, company, user), driven by a config registry.&lt;/li&gt;
&lt;li&gt;Profile hub: a single tabbed page for name and email, password, two-factor, PIN, sessions, avatar and UI preferences.&lt;/li&gt;
&lt;li&gt;Email templates: a super-admin editor for the subject and HTML body of the core emails, per language, stored in the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The email template editor, and why rendering is the scary part
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The danger is in how you render it. The lazy version is to push the stored string through Blade or some expression engine so &lt;code&gt;{{ $user-&amp;gt;name }}&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So the renderer does not use Blade and does not use eval. It is a literal single pass over &lt;code&gt;{{name}}&lt;/code&gt; tokens and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$keepUnknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;preg_replace_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'/\{\{\s*(\w+)\s*\}\}/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$keepUnknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_key_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$keepUnknown&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nv"&gt;$template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few properties fall out of this that matter:&lt;/p&gt;

&lt;p&gt;The substitution is a single pass. The replacement value is never re-scanned, so if a piece of data happens to contain &lt;code&gt;{{something}}&lt;/code&gt;, it cannot trigger a second round of substitution. No recursive expansion games.&lt;/p&gt;

&lt;p&gt;The stored string never reaches an expression engine. There is no compile step, no &lt;code&gt;eval&lt;/code&gt;, no Blade. A template in the database is structurally incapable of executing code, by construction, not by a blacklist I have to keep patching.&lt;/p&gt;

&lt;p&gt;Unknown tokens are emptied by default, so a recipient never sees a raw &lt;code&gt;{{token}}&lt;/code&gt; leaking into their inbox. The preview path passes &lt;code&gt;keepUnknown&lt;/code&gt; so an operator can still see what is wired up.&lt;/p&gt;

&lt;p&gt;That renderer is the primary safety boundary. Everything else is defense in depth:&lt;/p&gt;

&lt;p&gt;Strict variable whitelist. Each template declares the variables it is allowed to use. On save, every &lt;code&gt;{{variable}}&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;javascript:&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Sandboxed preview. The live preview renders inside an iframe with &lt;code&gt;sandbox&lt;/code&gt; and scripts off, so even the preview surface is a second independent barrier, not the only one.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A generic settings store with three scopes
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'settings'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'support_email'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'scope'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'signups_enabled'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'scope'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'timezone'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'scope'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'company'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'validation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'timezone'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="s1"&gt;'email_notifications'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'scope'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The profile hub
&lt;/h2&gt;

&lt;p&gt;The third piece pulls every self-service account screen into one tabbed page: profile fields, password, two-factor, PIN, active sessions, avatar and appearance.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing and integration
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow along
&lt;/h2&gt;

&lt;p&gt;This is one phase of an ongoing build-in-public series. The core is free and open.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;https://github.com/dmitryisaenko/larafoundry&lt;/a&gt; (star or follow the build)&lt;/li&gt;
&lt;li&gt;Project: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;https://larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I shipped a support desk by deleting a dependency</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Mon, 08 Jun 2026 15:22:42 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/i-shipped-a-support-desk-by-deleting-a-dependency-261k</link>
      <guid>https://dev.to/d_isaenko_dev/i-shipped-a-support-desk-by-deleting-a-dependency-261k</guid>
      <description>&lt;p&gt;I added a support desk to &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;LaraFoundry&lt;/a&gt; this week. The first commit in the slice removed a package instead of adding one.&lt;/p&gt;

&lt;p&gt;LaraFoundry is a reusable SaaS core for Laravel that I'm extracting in public from an older app of mine. Auth, multi-tenancy, roles, activity log, notifications, billing seam, and now support tickets. The rule for every module is the same: lift the proven idea out of the old code, modernise it, harden it, and make it something you can &lt;code&gt;composer require&lt;/code&gt; into a fresh Laravel app without inheriting a pile of assumptions.&lt;/p&gt;

&lt;p&gt;Tickets is where that rule got interesting, because the old code didn't own its ticket model. It leaned on a third-party ticket library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a ticket package is wrong for a reusable core
&lt;/h2&gt;

&lt;p&gt;A third-party ticket package is a perfectly reasonable choice when you're building one app. You get tables, a model, a status enum and a UI scaffold for free.&lt;/p&gt;

&lt;p&gt;It's the wrong choice for a core that other apps install. Pull it into the core and every host app inherits that package's migrations, its table names, its status vocabulary and its idea of what a ticket is. The dependency becomes load-bearing in projects that never asked for it, and the day it lags a Laravel release, every downstream app waits.&lt;/p&gt;

&lt;p&gt;So I cut it (decision D-4.2-1 in my notes) and wrote the model by hand. The model is about 180 lines. There is no magic. Two tables, a uuid, a status, a couple of scopes. The diff against "depend on the package" was less code in the core, not more, because I only kept the behaviour I actually use.&lt;/p&gt;

&lt;p&gt;Here's the top of the model, with the extraction notes I leave for future me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * A support ticket: the channel between a host user and the platform operator.
 *
 * Extracted from the donor App\Models\Ticket, which sat on a third-party
 * ticket package. That dependency is cut: this is a self-contained model.
 * Categories and labels are JSON slug arrays driven by config, not pivot
 * tables. The dead assigned_to column and the donor's invalid-operator
 * query are dropped.
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ticket&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Filterable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STATUS_WAIT_MODERATOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'wait-moderator'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STATUS_WAIT_CUSTOMER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'wait-customer'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STATUS_RESOLVED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'resolved'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'larafoundry_tickets'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part worth keeping: a status nobody sets
&lt;/h2&gt;

&lt;p&gt;The one genuinely good idea in the donor was its status flow, and it's the thing I kept.&lt;/p&gt;

&lt;p&gt;Most ticket systems give the operator a status dropdown. Open, pending, on hold, waiting, closed. The list grows, two of the values mean the same thing, and half of them are wrong at any given moment because somebody forgot to change the dropdown after they replied.&lt;/p&gt;

&lt;p&gt;LaraFoundry tickets have three statuses and you never pick one. The status is derived from who acted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  user opens a ticket
        |
        v
  wait-moderator  &amp;lt;-----------------------+
        |                                 |
        | operator replies                | user replies
        v                                 |
  wait-customer  --------------------------+
        |
        | operator resolves
        v
  resolved  ----&amp;gt; user replies ----&amp;gt; reopens (wait-moderator)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In code it's just this, in the two reply paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// User replies. It needs the operator's attention again, whatever it was before.&lt;/span&gt;
&lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STATUS_WAIT_MODERATOR&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Operator replies. The ball is back with the customer.&lt;/span&gt;
&lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STATUS_WAIT_CUSTOMER&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replying to a resolved ticket reopens it, because a reply to a closed thing means it wasn't actually closed. No dropdown, no stale state, nothing to forget.&lt;/p&gt;

&lt;p&gt;That same derived status drives the operator queue ordering. Open before resolved, brand-new before answered, high priority first, most recently updated last. I do it in one scope so the list always reads top to bottom as "deal with me first":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;scopeWorkflowOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"CASE WHEN status = 'resolved' THEN 1 ELSE 0 END asc"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"CASE
            WHEN created_at = updated_at THEN 0
            WHEN priority = 'high' THEN 1
            WHEN priority = 'standard' THEN 2
            ELSE 3 END asc"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"CASE
            WHEN status = 'wait-moderator' THEN 1
            WHEN status = 'wait-customer' THEN 2
            ELSE 3 END asc"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'desc'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;created_at = updated_at&lt;/code&gt; is a cheap trick for "nobody has touched this since it was opened", so a brand-new ticket floats to the very top without an extra column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extraction is a free code review
&lt;/h2&gt;

&lt;p&gt;The other thing extraction gives you, and nobody advertises this, is a forced reading of code you wrote a long time ago and then stopped looking at.&lt;/p&gt;

&lt;p&gt;To lift the ticket logic out cleanly I had to read every line of the donor. I found bugs I'd been running without noticing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A query method built a filter with &lt;code&gt;'!=='&lt;/code&gt; as its SQL operator. &lt;code&gt;'!=='&lt;/code&gt; is JavaScript. In SQL it's a syntax error, which means that code path had never actually run. I dropped the method.&lt;/li&gt;
&lt;li&gt;A queue filter was inverted. The flag that was supposed to show all tickets was being read the wrong way round, so the "all" view and the "open" view were quietly swapped in one branch.&lt;/li&gt;
&lt;li&gt;There was an &lt;code&gt;assigned_to&lt;/code&gt; column that nothing wrote to and nothing read. A leftover from the package's model that my app never used. Gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these were dramatic. That's the point. They were the kind of quiet wrongness that survives for years because the feature mostly works and nobody re-reads it. Extracting a module into a place where it has to stand on its own is the cheapest code review I know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config, not pivot tables
&lt;/h2&gt;

&lt;p&gt;Categories and labels are the kind of thing you reach for a pivot table for by reflex. A &lt;code&gt;categories&lt;/code&gt; table, a &lt;code&gt;ticket_category&lt;/code&gt; pivot, a model, a seeder.&lt;/p&gt;

&lt;p&gt;For a core that other people configure, that's heavy. So categories and labels are JSON slug arrays on the ticket, driven by config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/larafoundry-tickets.php&lt;/span&gt;
&lt;span class="s1"&gt;'categories'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'general'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'feature'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bug'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="s1"&gt;'labels'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'quick'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'complex'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A host app adds a category by editing an array. No migration, no new model, no seeder. The slugs you allow are validated against that same config on the way in, so a request can't invent a category that isn't on the list. When a host eventually wants database-managed categories, the column is already JSON and the swap is local. Until then, this is the lightest thing that works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one screen a suspended customer must still reach
&lt;/h2&gt;

&lt;p&gt;Here's the product decision I spent the most time on, and it's a one-line decision in the routes file.&lt;/p&gt;

&lt;p&gt;The ticket routes sit behind &lt;code&gt;auth&lt;/code&gt; and nothing else. No &lt;code&gt;verified&lt;/code&gt; gate, and no account-active gate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tickets'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tickets.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// the customer's own tickets&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason is support is the last channel. When a company gets suspended (say their billing lapsed and the tenant is blocked from the rest of the app) the support page is the one screen they must still be able to open, because it's their only line back to me. Gate it like everything else and you've locked a paying-again customer out of the room where they'd tell you they want to pay again.&lt;/p&gt;

&lt;p&gt;There's a sharp edge here that only showed up when I integrated the module into the host app, and I like it as an example of why integration tests earn their keep.&lt;/p&gt;

&lt;p&gt;The host applies a global middleware that hard-logs-out blocked or deleted accounts on every web route. So I had two ideas of "blocked" colliding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A company suspended for billing. The user's own account is fine. They should reach support.&lt;/li&gt;
&lt;li&gt;An account an operator personally banned. That one should be gone everywhere, support included.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The middleware that enforces the second one looks only at identity (is this account banned or deleted), and it deliberately knows nothing about companies. A company suspension never reaches it, so a billing-blocked user sails through to the ticket page. An operator-banned account is stopped at the door on every route. Two kinds of blocked, one deliberate line between them, and the only way I trusted that line was a test that drives the real host middleware stack and checks both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing last month's seam
&lt;/h2&gt;

&lt;p&gt;Phase 4.1 was an in-app notification centre with a small service seam: hand it some users, a code and a couple of translation keys, and it delivers an in-app notification.&lt;/p&gt;

&lt;p&gt;The ticket module didn't grow its own notification logic. When an operator replies, it calls that seam:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;system&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'info'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;titleKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'larafoundry::tickets.notify.reply.title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The author gets a bell notification that an operator answered, localised to their language, and the ticket module stays out of the notification business entirely. Seams from one phase paying off in the next is the whole reason I freeze them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security pass
&lt;/h2&gt;

&lt;p&gt;Self-written means I own the security, so the module got the same review gate every module gets: a few independent agents reading the diff adversarially before it merges. The things that mattered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The route key is the uuid, never the sequential id, so a customer's URL never leaks how many tickets exist. On top of that an ownership policy authorises every action, so asking for someone else's ticket is a 403, not a peek.&lt;/li&gt;
&lt;li&gt;Message bodies render as text, never &lt;code&gt;v-html&lt;/code&gt;. A ticket is user-submitted content and the operator reads it in their console, so an unescaped body is a stored XSS straight at the admin. The message list component renders the body with text interpolation and whitespace preserved, and a Vitest test pins that escaping so a future refactor can't quietly turn it into HTML.&lt;/li&gt;
&lt;li&gt;Opening tickets and replying are throttled, with the limits in config, so a script can't flood the support queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;Tickets added 30 Pest tests to the core (it now sits at 556) and 8 Vitest tests for the Vue side (132). The model, the policy, the user flow, the operator console, the filter, and the two reply transitions all have coverage.&lt;/p&gt;

&lt;p&gt;Then there's one more layer that I've come to rely on: a host integration test. The package has its own test suite against a bare test app, but the host app is where the real middleware stack, the real user model and the real tenancy live. The integration test opens a ticket as a real user, has an operator reply, and asserts the author got the notification, that a second user gets a 403 on someone else's ticket, that an unverified user can still reach support, and that the support menu item shows up for the operator. That's the test that caught the two-kinds-of-blocked edge above.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's free
&lt;/h2&gt;

&lt;p&gt;Tickets is FREE core, like auth and tenancy and notifications. The paid part of LaraFoundry is the billing add-on, not the things every SaaS needs. So all of the code in this post is exactly what ships, and you can read the rest in the repo.&lt;/p&gt;

&lt;p&gt;The next step is writing the reference doc for the module while the details are still fresh, which is its own small discipline I'll write about separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;github.com/dmitryisaenko/larafoundry&lt;/a&gt; (star or follow the build)&lt;/li&gt;
&lt;li&gt;Project: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>saas</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"Notifications without WebSockets: an in-app centre and broadcasts on shared hosting</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Mon, 08 Jun 2026 11:22:39 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/notifications-without-websockets-an-in-app-centre-and-broadcasts-on-shared-hosting-4l99</link>
      <guid>https://dev.to/d_isaenko_dev/notifications-without-websockets-an-in-app-centre-and-broadcasts-on-shared-hosting-4l99</guid>
      <description>&lt;p&gt;Every "add notifications to your Laravel app" tutorial seems to start the same way: install Pusher or Reverb, wire up Echo, maybe spin up Redis. That is a fine setup once you actually need a live feed. But for an in-app notification centre, a bell with an unread badge and an inbox, it is a lot of moving infrastructure for something you can do with a queue and a cron line.&lt;/p&gt;

&lt;p&gt;LaraFoundry, the reusable SaaS core I am extracting in public from a CRM that already runs in production, has a hard rule: it must run on plain shared hosting. No mandatory Redis, no Horizon, no long-running daemons. So when I built the notifications module (phase 4.1, tag v0.14.x) I had to deliver a real notification centre and super-admin broadcasts under that constraint. Here is how it went together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bell does not need a socket
&lt;/h2&gt;

&lt;p&gt;The unread badge is a poll, not a push. The bell hits a tiny &lt;code&gt;unread-count&lt;/code&gt; endpoint on a light interval, and only while the tab is actually visible, so a backgrounded tab goes quiet. The recent list is fetched when you open the dropdown, not on a timer. The full inbox is a normal paginated page.&lt;/p&gt;

&lt;p&gt;That is genuinely enough for an in-app centre. A user who has the app open sees their count refresh within a minute, and sees everything the moment they open the bell. Realtime delivery is a nice upgrade to layer on later through the channels seam, but it is not a dependency you need to start with. Shipping the 90 percent that works on any host beats blocking on infrastructure most small SaaS never provision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broadcasts that do not block the request
&lt;/h2&gt;

&lt;p&gt;The interesting half is super-admin broadcasts. An operator drafts a message with per-locale text, picks an audience, and sends. The audience filters are the domain-independent ones the core can own: verified users, recently active users, or users holding a given RBAC role. Demographic targeting (country, age) stays in the host, where that data lives.&lt;/p&gt;

&lt;p&gt;The naive version of "send to everyone" attaches every matched user in one synchronous insert inside the request. On a large user base that blocks the request and balloons memory. So the send queues a job, resolves the audience from the stored filters, walks it in id-ordered chunks, and inserts each chunk with &lt;code&gt;insertOrIgnore&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A broadcast fans out to its audience, queued and chunked. insertOrIgnore keeps a retried job idempotent, no giant insert.&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;recipientQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'larafoundry_notification_user'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insertOrIgnore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'notification_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'created_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. &lt;code&gt;insertOrIgnore&lt;/code&gt; against a unique &lt;code&gt;(notification_id, user_id)&lt;/code&gt; index means a retried job can never double-deliver. And the job only acts while the broadcast is in a &lt;code&gt;sending&lt;/code&gt; state: once it finishes and flips to &lt;code&gt;sent&lt;/code&gt;, a retry returns early instead of re-firing the audited "broadcast sent" event. The fan-out is idempotent, and so is the audit trail. It all runs on the database queue, so a single &lt;code&gt;queue:work&lt;/code&gt; under cron is the only moving part.&lt;/p&gt;

&lt;h2&gt;
  
  
  A notification is content someone else wrote
&lt;/h2&gt;

&lt;p&gt;This is the part I cared about most. A broadcast body and a system notification both end up as stored text that gets rendered into a user's browser. That is attacker-adjacent input, so I treated it that way.&lt;/p&gt;

&lt;p&gt;Titles and bodies render as text, never &lt;code&gt;v-html&lt;/code&gt;. A notification can carry actions, but each action is reduced to an internal, same-origin GET link before it ever reaches the frontend: a single leading slash, never a protocol-relative &lt;code&gt;//host&lt;/code&gt;, never a backslash that a browser would normalise back off-site. The HTTP method is dropped, so a stored action can never drive a POST or a DELETE or bounce you to another origin.&lt;/p&gt;

&lt;p&gt;And the inbox itself is scoped. Every read and every mutation goes through the user's own relation, so asking for another user's notification id simply finds nothing and returns a 404. No "mark as read" on a row you do not own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The host pushes notifications through one seam
&lt;/h2&gt;

&lt;p&gt;Your application should not write notification rows by hand. It calls one service, with translation keys instead of baked strings, so the message localises per recipient at read time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Send an in-app notification from your own domain event. Wording is translation keys, localised per recipient.&lt;/span&gt;
&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;system&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$company&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;titleKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Welcome to :company'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'company'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$company&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the host I wired this to a domain event: when a company is created, its owner gets a welcome notification. The whole integration on the host side is small. Add a trait to the user model for the inbox relation, run the migration, publish the pages, and the bell shows up in the header because it ships in the core layout. Sending from your own events is the six lines above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof, not vibes
&lt;/h2&gt;

&lt;p&gt;None of this ships on faith. The module is covered with Pest on the backend and Vitest on the frontend, both green before the tag went out, plus the inbox scoping, the IDOR check, the broadcast fan-out and the super-admin exclusion are all asserted end to end against a real tenancy in the host app. The XSS-escaping of titles and bodies has its own frontend tests. Test count went up by a few dozen on this phase alone.&lt;/p&gt;

&lt;p&gt;Building in public means the green suite is part of the story, not a footnote.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Star or follow the build on GitHub: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;https://github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Project site: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;https://larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>vue</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How a solo dev builds like a team: freeze the seams, not the plan</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Mon, 08 Jun 2026 08:52:22 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/how-a-solo-dev-builds-like-a-team-freeze-the-seams-not-the-plan-1a2i</link>
      <guid>https://dev.to/d_isaenko_dev/how-a-solo-dev-builds-like-a-team-freeze-the-seams-not-the-plan-1a2i</guid>
      <description>&lt;p&gt;I build LaraFoundry alone. One person, one branch, one task at a time. A reusable SaaS engine for Laravel, extracted out of a real CRM (Kohana.io) that I am building in public.&lt;/p&gt;

&lt;p&gt;Working solo, I never hit the question that breaks most teams: how do two people build two parts of the same system at the same time without stepping on each other.&lt;/p&gt;

&lt;p&gt;Then I asked myself that question anyway, just as a thought experiment. And the answer changed how I see my own codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I almost fell into
&lt;/h2&gt;

&lt;p&gt;When you imagine handing a big project to a team, the instinct is obvious. You think: I need a giant document first. Every route, every file name, every method, what it takes in, what it returns, every test and what it should assert, every security rule. One huge map of the whole thing, so two people never collide.&lt;/p&gt;

&lt;p&gt;That feels like the grown-up way to do it. It is not. It has a name in the industry, Big Design Up Front, and teams moved away from it on purpose.&lt;/p&gt;

&lt;p&gt;Two reasons it fails:&lt;/p&gt;

&lt;p&gt;The map rots faster than you can write it. By the time you finish specifying every method of module E, building module A teaches you that three of those methods are wrong and two are missing. You wrote a document just to throw it away.&lt;/p&gt;

&lt;p&gt;Detail is not the same as safety. A risky project does not get safer because the plan has more words in it. It gets safer when you test the expensive assumptions early. Writing a method signature is cheap and proves nothing. Building one vertical slice that actually runs through tenancy, billing and auth is expensive and proves everything.&lt;/p&gt;

&lt;p&gt;So the giant upfront diagram is not the ideal you failed to reach. It is the thing you were right to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually lets two people work in parallel
&lt;/h2&gt;

&lt;p&gt;Teams do not write the giant map. They do three things instead.&lt;/p&gt;

&lt;p&gt;They split the system by seams, not by phases. My phases (A, then E) are just one person slicing time. A team slices by module with clear borders. Auth, Tenancy, Billing, Navigation. Each one owned by someone, each one with a small public contract, and total freedom inside. You can parallelize exactly as much as your borders are clean.&lt;/p&gt;

&lt;p&gt;They freeze contracts, not implementations. This is the key. To stop team B waiting on team A, you fix the interface between them and nothing else. Not every method of a class. Just the one contract through which A and B talk. An interface plus a DTO. Team B writes against the interface, team A implements it. B does not wait, B mocks the interface and keeps going.&lt;/p&gt;

&lt;p&gt;They track dependencies as a graph, not a wall of text. Task E depends on contract C, which A must freeze. Once C is frozen (frozen, not implemented), E can start against a mock. Most of the time you discover that 80 percent of the "dependencies" only need a frozen contract, not a finished implementation, and you can parallelize almost everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Freeze" does not mean carved in stone
&lt;/h2&gt;

&lt;p&gt;Freezing a contract means: from this point, other people build on this shape, and nobody changes it silently and alone.&lt;/p&gt;

&lt;p&gt;It is not forbidden to change. The cost just jumped. Before you freeze, change it however you like, it is your draft, nobody leans on it, free. After you freeze, you can still change it, but it is no longer your private decision. You are breaking someone else's work. So there is a protocol: announce it, agree with whoever depends on it, ship it as a versioned change, let everyone migrate.&lt;/p&gt;

&lt;p&gt;That is the whole point of a freeze. It is the moment a change stops being an edit in your editor and becomes an event with a protocol. That jump in cost is exactly what gives everyone else permission to build on top of you in peace.&lt;/p&gt;

&lt;p&gt;And you freeze the shape, not the guts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayManager&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Frozen: the name, the inputs, the return type.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Money&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Customer&lt;/span&gt; &lt;span class="nv"&gt;$customer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ChargeResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Frozen: the method is called &lt;code&gt;charge&lt;/code&gt;, it takes &lt;code&gt;Money&lt;/code&gt; and &lt;code&gt;Customer&lt;/code&gt;, it returns a &lt;code&gt;ChargeResult&lt;/code&gt;. Not frozen: how it works inside, Stripe or Paddle, how many private methods it has. Team A can rewrite the guts ten times and team B never notices, because the contract did not move. You freeze the narrow border and leave full freedom behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plot twist on my own code
&lt;/h2&gt;

&lt;p&gt;Here is what hit me. I went looking through LaraFoundry for the parts that would survive a team. And they were already there.&lt;/p&gt;

&lt;p&gt;The billing add-on talks to the free core through a contract called &lt;code&gt;EntitlementResolver&lt;/code&gt;. Navigation plugs into the app through a &lt;code&gt;MenuProviderInterface&lt;/code&gt;. The admin dashboard accepts widgets through a &lt;code&gt;DashboardWidgetProvider&lt;/code&gt;. Events carry a fixed payload.&lt;/p&gt;

&lt;p&gt;I had been calling these "seams" the whole time. I built the billing add-on entirely against the core's contracts, and the core did not change while I did it (the tag stayed put). That is a freeze in action. The add-on leaned on a shape, the shape held, the add-on shipped on its own track.&lt;/p&gt;

&lt;p&gt;So I had stumbled into the exact mechanism a team uses to work in parallel. I just was not using its main superpower, because solo and sequential I never needed to.&lt;/p&gt;

&lt;p&gt;This is the honest version of the story. Not "I planned it all perfectly from day one." I did not. My seams were born inside the phase where I first needed them, not frozen ahead of time. The realization is the other way around: the question "how would this survive a team" showed me that the thing I already do for decoupling is the same thing teams do for parallelism.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would change the day a second person shows up
&lt;/h2&gt;

&lt;p&gt;Not "write the giant map." This:&lt;/p&gt;

&lt;p&gt;Pull the contracts into a frozen layer earlier. Right now my seams appear together with the phase that first needs them. On a team you flip it: one session where you freeze all the cross-module interfaces and event payloads first, with null stubs behind them, then the phases fan out to people.&lt;/p&gt;

&lt;p&gt;Write decisions down as records, not as my own memory. Solo, my decisions live in my head and my notes. A team needs the same decisions as small versioned records in the repo, so everyone can see why tenancy is fail-closed without asking me.&lt;/p&gt;

&lt;p&gt;Mark each dependency as "blocks on a contract" or "blocks on an implementation." The first unblocks the moment you freeze. The second actually waits. You usually find most of them are the first kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to think about depth of planning
&lt;/h2&gt;

&lt;p&gt;Planning depth is not uniform.&lt;/p&gt;

&lt;p&gt;Module borders and the contracts between them: detailed and early. Expensive to change, they block other people.&lt;/p&gt;

&lt;p&gt;Module internals: a sketch, details as you go. Cheap to change, they block nobody.&lt;/p&gt;

&lt;p&gt;Tests: written as an executable spec, not described in prose. A Pest test is the formal record of what goes in and what comes out. That is why teams do not write "which tests and what they return" in a plan. They write the tests. My core sits on a Pest suite for exactly this reason, the tests are the contract, not the paragraph about the tests.&lt;/p&gt;

&lt;p&gt;One picture for it: you plan the doorways between rooms in detail, where they are, how wide, because otherwise the walls will not meet. You do not plan the furniture inside a room. Whoever moves in decides that.&lt;/p&gt;

&lt;h2&gt;
  
  
  So where did I go wrong
&lt;/h2&gt;

&lt;p&gt;Mostly nowhere. Extract, increment, seams. That is the right shape for a non-trivial project.&lt;/p&gt;

&lt;p&gt;The giant upfront schema is an anti-pattern, not an ideal I missed. The secret to parallel work is not "write everything down." It is freeze the narrow contracts at the borders before the implementations diverge, and work against mocks. My seams were that mechanism the whole time. The only thing I would add for a team is to freeze them earlier, and to write down which dependencies are real and which dissolve the moment a contract is frozen.&lt;/p&gt;

&lt;p&gt;If you are building solo and wondering whether you are doing it "properly" for a real project: look at your own decoupling points. The interfaces you made just to keep modules apart. Those are your freeze points. You are closer to how a team works than the giant-document instinct would ever get you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Star or follow the build on GitHub: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;https://github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Project and updates: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;https://larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Extracting a QR login from a production app and closing 11 security holes before merge</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Mon, 08 Jun 2026 08:41:50 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/extracting-a-qr-login-from-a-production-app-and-closing-11-security-holes-before-merge-47nc</link>
      <guid>https://dev.to/d_isaenko_dev/extracting-a-qr-login-from-a-production-app-and-closing-11-security-holes-before-merge-47nc</guid>
      <description>&lt;p&gt;QR login is one of those features that looks tiny on the roadmap and turns out to be a security minefield. You show a QR code in the browser, scan it with a phone you are already signed in on, and the web session logs itself in. WhatsApp Web, Telegram Web, Steam. Everyone has used it. Nobody thinks about what happens behind it.&lt;/p&gt;

&lt;p&gt;I had a working version of this in an app I already run in production. So when it came time to add cross-device login to the LaraFoundry core, I did what I do with every module: I extracted the real code instead of writing a fresh one from a blog post. Extracting beats greenfield because the logic already survived contact with real users. But there is a catch, and it is the whole point of this post: code that works is not the same as code that is safe. The donor version worked fine and had eleven things I was not willing to ship.&lt;/p&gt;

&lt;p&gt;This is the list. Every hole, and what it became.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow, in one paragraph
&lt;/h2&gt;

&lt;p&gt;The browser (a guest, not logged in) asks the backend for a sign-in request and renders it as a QR. A second device that is already authenticated scans the code and hits a verify endpoint to approve it. Meanwhile the browser polls until the request flips to approved, then logs the user in. Three endpoints: generate, verify, poll. Simple shape, lots of sharp edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eleven
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;The donor did this&lt;/th&gt;
&lt;th&gt;The core does this&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Stored the raw token in the DB and put it in the QR URL&lt;/td&gt;
&lt;td&gt;Stores a SHA-256 hash; the plaintext lives only in the QR image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;No rate limit on any endpoint&lt;/td&gt;
&lt;td&gt;Throttle on generate, poll and verify (verify is the brute-force surface)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;axios.get(decodedText)&lt;/code&gt; on whatever the QR decoded to&lt;/td&gt;
&lt;td&gt;Validates the scanned URL (same origin plus the exact verify path) before any request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;TTL slid forward forever on reuse&lt;/td&gt;
&lt;td&gt;Absolute cap measured from creation, so a code dies even if it keeps refreshing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Expired rows piled up forever&lt;/td&gt;
&lt;td&gt;A prune command the host schedules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Auth::login()&lt;/code&gt; then &lt;code&gt;session()-&amp;gt;regenerate()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Regenerate first, then log in (session fixation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;No audit of attempts&lt;/td&gt;
&lt;td&gt;Every approve, fail and block goes to the activity log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;No indexes on the columns it queried&lt;/td&gt;
&lt;td&gt;Indexes on the poll and prune predicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Decrypted the id from the URL, so a bad value threw a 500&lt;/td&gt;
&lt;td&gt;No decryption at all; a bad code is a clean 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Encrypted the token AND the id into the session, twice&lt;/td&gt;
&lt;td&gt;Stores only the row id, once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;An unexplained &lt;code&gt;subSeconds(13)&lt;/code&gt; fudge on the expiry check&lt;/td&gt;
&lt;td&gt;Gone; a plain &lt;code&gt;expires_at &amp;gt; now()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these are worth showing in code, because they are the ones people get wrong everywhere, not just here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hole 1: the token was plaintext in the database
&lt;/h3&gt;

&lt;p&gt;The donor generated a UUID, stored it as-is, and embedded it in the QR URL. If your database leaks, every live QR code leaks with it. The token is a bearer secret, so it gets the same treatment as a password: hash it, compare hashes, never persist the original.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// generate(): the plaintext only ever lives in the QR, the DB gets a hash&lt;/span&gt;
&lt;span class="nv"&gt;$plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$signInRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SignInRequest&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'token'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$plain&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'expires_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ttlMinutes&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="s1"&gt;'ip_address'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'user_agent'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// verify(): look the row up by the hash of the token from the URL&lt;/span&gt;
&lt;span class="nv"&gt;$signInRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SignInRequest&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'expires_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nice side effect of dropping the encrypt/decrypt dance: there is nothing left to throw. The donor wrapped the id in &lt;code&gt;Crypt::encrypt()&lt;/code&gt;, so a malformed value blew up with a 500 (hole 9). Hashing is a string compare. A wrong code just does not match, and you return a clean 404.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hole 3: the scanner fetched whatever it decoded
&lt;/h3&gt;

&lt;p&gt;This one made me wince. The mobile side did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the donor: fetch literally anything the camera decoded&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onScanSuccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decodedText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decodedText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A QR code is attacker-controlled input. Point the camera at a malicious code and the page will happily issue a request to any URL it contains. That is a server-side request forgery and open-redirect vector handed to you for free. The fix is to refuse to fetch anything that is not our own verify endpoint, same origin, right path, real scheme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeVerifyUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// reject blob:/data:/javascript: before anything else&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(?:\/[^/]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?\/&lt;/span&gt;&lt;span class="sr"&gt;larafoundry&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;qr&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;verify&lt;/span&gt;&lt;span class="se"&gt;\/\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/[^/]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL parser normalizes encoding and path traversal before the regex ever runs, so the &lt;code&gt;..%2f&lt;/code&gt; tricks do not get through. Origin is checked against the live origin, not a string match, so &lt;code&gt;localhost.evil.com&lt;/code&gt; and userinfo tricks like &lt;code&gt;app@evil.com&lt;/code&gt; fail too. A reviewer later threw 25 bypass vectors at this and every dangerous one bounced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hole 6: log in first, ask questions later
&lt;/h3&gt;

&lt;p&gt;The donor logged the user in and then regenerated the session. That ordering is a session-fixation invitation: an attacker who fixes the session id before login keeps a valid handle after it. Flip the two lines and the fixated id is thrown away before the user is ever attached to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// poll(): new session id BEFORE the identity is attached to it&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;regenerate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$signInRequest&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// single-use: the approved code cannot be replayed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;delete()&lt;/code&gt; is its own small fix. The donor left the approved row sitting there, so the same code could be polled again. One use, then it is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing the donor never had: the super-admin block
&lt;/h2&gt;

&lt;p&gt;There is a rule in the platform that the operator account cannot sign in by QR. The donor checked a raw &lt;code&gt;is_admin&lt;/code&gt; column. The core already has a single resolver for "is this the platform super-admin", with a case-insensitive email allow-list on top of the flag, so a flipped column alone cannot grant it. The QR verify reuses that one resolver instead of inventing a second check that can drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;visitorStatus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$approver&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// audited, then refused&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'larafoundry::auth.qr.admin_forbidden'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One source of truth for a security decision beats two that agree today and disagree after a refactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  While I was in there: page or modal
&lt;/h2&gt;

&lt;p&gt;The same phase shipped a small unrelated quality-of-life thing. The auth screens (login, register, forgot, reset) can now render either as a full page or as a modal over the host's content, switched by one config value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'presentation'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LARAFOUNDRY_AUTH_PRESENTATION'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'page'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// page | modal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default is &lt;code&gt;page&lt;/code&gt;, so nothing changes for anyone who does not opt in. The donor only had modals, the core only had pages. Neither had the switch. Now one config key picks the surface and the same screen component renders both ways. An unknown value falls back to &lt;code&gt;page&lt;/code&gt;, so a typo can never produce an undefined surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof, not vibes
&lt;/h2&gt;

&lt;p&gt;Every module in the core ships with Pest tests, and a security feature gets the heaviest coverage. The QR backend has a test that runs the full two-device handshake inside one process (two independent sessions): a guest generates, an approver verifies, the guest polls and ends up authenticated, the row is consumed. On top of that: the token is stored hashed and never in plaintext, an expired code is refused, the absolute cap holds, a super-admin is blocked with a 403, a bogus token returns a clean 404 and never a 500, the verify works under both a web session and a Bearer token, and the prune command clears the right rows. The full core suite is 497 tests green.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I am proudest of is the part that caught me
&lt;/h2&gt;

&lt;p&gt;Here is the build-in-public bit. Every sub-phase of this work went through two adversarial reviewers before merge, whose job is to try to break it, not to praise it. On the QR backend they caught a HIGH that I had missed: the verify URL carries the token, and the activity log was recording the full request URL. So the token I had so carefully hashed in the database was sitting in plaintext in the audit log. The hashing was undone by the logging.&lt;/p&gt;

&lt;p&gt;The fix was systemic, not a patch on one line: the activity context now redacts secret path segments (by route-parameter name) the same way it already redacted query-string secrets. One reviewer, one HIGH, one better fix than I would have written if I had shipped on my own confidence. That is the whole reason the review gate exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow along
&lt;/h2&gt;

&lt;p&gt;This is part of LaraFoundry, a reusable SaaS/CRM core for Laravel that I am extracting in public from real, production code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub (star and follow the build): &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;https://github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Project: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;https://larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The guard-agnostic verify endpoint (one controller that serves both a web session today and a mobile Bearer token tomorrow) deserves its own short write-up. That is the next post in the series.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a SaaS engine in public: a billing engine that isn't married to Stripe</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Mon, 08 Jun 2026 08:30:39 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-a-billing-engine-that-isnt-married-to-stripe-2i8i</link>
      <guid>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-a-billing-engine-that-isnt-married-to-stripe-2i8i</guid>
      <description>&lt;p&gt;A while back in this series I shipped the billing &lt;em&gt;seam&lt;/em&gt; and was very careful to say it was not billing. The free core got the whole shape of a subscription system, a gateway contract, a driver manager, a real access gate, and it could not take a single cent. That was on purpose. The cent-taking part lives in a separate paid add-on, and this post is about finishing the Lean MVP of that add-on.&lt;/p&gt;

&lt;p&gt;LaraFoundry is a SaaS core I am extracting in public from a CRM that already runs in production, one module at a time. The core is free and stays free for everything except money. Auth, multi-tenancy, RBAC, the admin console, the activity log, i18n, files: all free. The day a business wants to charge its own customers, that is the paid part. So billing could not be "just another module." It had to split down a clean line: the free side carries the structure, the paid side carries the parts that actually move money.&lt;/p&gt;

&lt;p&gt;Here is what "Lean MVP done" means concretely. Plans with real prices. Checkout that opens a real hosted payment page. Promo codes and a free first month. Subscription enforcement that blocks access when the money runs out. An owner-facing billing portal and a super-admin console to watch payments and manage promo codes. A reminder cron before a subscription lapses. And the single decision that shaped every line of it: none of it is married to one payment provider, and nothing is priced in one hardcoded currency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint that shaped everything
&lt;/h2&gt;

&lt;p&gt;Most Laravel SaaS starters are Stripe-only. Stripe is excellent, and for a lot of builders that is the right and only answer. But Stripe reaches roughly 46 countries, and the market I build for is not one of them. If I bake Stripe into the call sites, I have quietly built a core that only serves the countries Stripe serves.&lt;/p&gt;

&lt;p&gt;So the brief I gave myself was uncomfortable on purpose: build billing as if I do not know which provider the host will use, and as if the host's customers do not all pay in dollars. Everything below falls out of taking that seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  One contract, many drivers
&lt;/h2&gt;

&lt;p&gt;The free core ships a single contract that describes only the mechanics of moving money:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Tenant&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$planId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Tenant&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$atPeriodEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$chargeReference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;subscriptionStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Tenant&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// returns the verified payload, throws if not authentic&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire surface. Notice what is not in it: nothing about tax, nothing about invoicing. That omission is deliberate. Tax responsibility differs by the &lt;em&gt;kind&lt;/em&gt; of provider. With a PSP like Stripe the business is the merchant of record and owns its own VAT. With a merchant of record like Paddle, the provider owns the tax. If I folded a &lt;code&gt;chargeTax()&lt;/code&gt; or an &lt;code&gt;invoice()&lt;/code&gt; into the gateway method, I would bake one of those two models into both, and it would be wrong for the other half of my providers.&lt;/p&gt;

&lt;p&gt;The paid add-on registers real drivers against that contract, in the same style as Laravel's own Mail or Queue managers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the add-on's service provider. The form is public API; the driver bodies are the add-on.&lt;/span&gt;
&lt;span class="nv"&gt;$manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stripe'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CashierStripeDriver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$manager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'paddle'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CashierPaddleDriver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A config key picks the active driver. The call sites that start a subscription or read its status never know which gateway they got. A host in a country neither Stripe nor Paddle reaches can register its own local PSP against the same contract and change one config value. Swapping the entire payment provider is not a refactor, it is a string.&lt;/p&gt;

&lt;p&gt;Both real drivers are built on Laravel Cashier (the Stripe one on &lt;code&gt;laravel/cashier&lt;/code&gt;, the Paddle one on &lt;code&gt;cashier-paddle&lt;/code&gt;). Cashier and not Spark, because Spark is an opinionated whole-app billing UI and I needed a gateway, not a front end. The driver bodies, the webhook verification, the column mirroring, are the proprietary part of the add-on, so I will describe them rather than dump them. The shape is: the driver opens a hosted checkout and hands back a redirect, and a webhook listener mirrors the provider's subscription state back onto the company's own columns, so the rest of the app reads one set of columns no matter which provider wrote them.&lt;/p&gt;

&lt;p&gt;Stripe and Paddle are not the same animal, and the contract has to absorb that without leaking it. Stripe's &lt;code&gt;subscribe&lt;/code&gt; returns a hosted Checkout URL to redirect to. Paddle's overlay wants a payload handed to its JavaScript instead of a server redirect. Both satisfy the same method signature and return shape, and the caller treats them identically. The one honest seam in the contract is &lt;code&gt;refund&lt;/code&gt;: against a merchant of record, a programmatic refund goes through the provider's own adjustments flow, so that path is stubbed to throw clearly rather than pretend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plans are priced per currency
&lt;/h2&gt;

&lt;p&gt;The other half of "not US-first" is the money itself. A plan in LaraFoundry does not have &lt;em&gt;a price&lt;/em&gt;. It has a price per currency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$plans&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'business'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;priceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'EUR'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1900   (minor units, 19.00 EUR)&lt;/span&gt;
&lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;priceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'year'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'PLN'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 79000  (790.00 PLN)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no single USD figure with a converter bolted on at display time. A customer in Warsaw is billed in their currency, with a price I actually chose for that currency, not a live FX-rounded approximation of a dollar amount. The currency is part of the plan definition, decided up front, not computed in the view. The &lt;code&gt;PlanContract&lt;/code&gt; and &lt;code&gt;priceFor&lt;/code&gt; live in the open core, so a free self-host gets the currency-first shape for free; the paid add-on supplies the config-backed plans and maps each plan and currency to the right provider price ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the free/paid line falls
&lt;/h2&gt;

&lt;p&gt;This is the part I keep coming back to across the whole series. The free core ships the &lt;em&gt;seam&lt;/em&gt;: the gateway contract, the access gate on the company model, and the subscription columns themselves (&lt;code&gt;trial_ends_at&lt;/code&gt;, &lt;code&gt;subscription_ends_at&lt;/code&gt;, &lt;code&gt;plan_id&lt;/code&gt;). With billing turned off, which is the default, the gate is always open and the core is a complete multi-tenant app with no paywall anywhere. That is the entire promise of the free core.&lt;/p&gt;

&lt;p&gt;The paid add-on fills the seam. It binds the real gateways, it writes those columns from webhooks, and it closes one more loop: entitlements. Access in a SaaS is really two independent questions, and collapsing them is a classic bug. RBAC answers "can this user do it." Entitlement answers "did this company's plan pay for it." A manager can legitimately hold &lt;code&gt;production.view&lt;/code&gt; in RBAC while the company sits on a plan that never bought the Production module. So a route is gated by both at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'can:production.view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// RBAC: who in the company may&lt;/span&gt;
    &lt;span class="s1"&gt;'entitlement:production.module'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// billing: what the plan paid for&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/production'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductionController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free core ships an entitlement resolver that is open by default. The add-on rebinds it to read the plan's features and refuse anything the plan did not buy. Billing off, every feature is open. Billing on, it is fail-closed and resolved server-side, with nothing to spoof from the request.&lt;/p&gt;

&lt;p&gt;Enforcement of an expired or unpaid subscription has two modes the host picks. Soft mode lets the request through but surfaces the state, so you can nudge before you lock. Hard mode redirects to a blocked page. One case is always hard regardless of mode: if the account owner's own access is revoked, the block cascades to the whole company, because a company cannot operate on a banned owner's seat. A daily cron warns owners a few days before a subscription lapses, idempotently, so nobody gets the same reminder twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surfaces, briefly
&lt;/h2&gt;

&lt;p&gt;Two UI surfaces shipped with the MVP. The owner gets a billing portal: pricing, checkout, payment history, and the blocked page. The super-admin gets a console: a read-only list of payments (totalled per currency, with no converter, because the per-currency truth is the honest one) and full CRUD over promo codes. Both are built as Inertia + Vue pages the host publishes, the same delivery pattern the rest of the core's frontend uses, so the add-on does not drag in a second build pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof, because every release claims one
&lt;/h2&gt;

&lt;p&gt;All of this sits under Pest. 241 tests in the add-on alone. I lean on tests hard here for a specific reason: a billing gate you cannot trust is worse than no gate, and the failure modes are quiet. A webhook listener that writes a &lt;code&gt;null&lt;/code&gt; expiry date silently revokes a paying customer. A fail-open guard silently gives away the paid tier. Several of those exact bugs got caught by a test or an adversarial review pass before they ever shipped, and that is the whole argument for writing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honesty section
&lt;/h2&gt;

&lt;p&gt;Every post in this series has one, and this one earns a big one because "billing is done" is an easy thing to overclaim.&lt;/p&gt;

&lt;p&gt;This is a Lean MVP, not a finished billing product. There is no affiliate program yet; that is a deliberately later milestone. The entitlement loop checks plan membership, the "is the subscription still alive" axis is enforced separately, and I am not pretending those are the same check.&lt;/p&gt;

&lt;p&gt;And the big one: I have built and tested all of this, but I have not yet run a live charge against a real Stripe or Paddle account end to end. The drivers are exercised against the providers' test surfaces and a wall of Pest tests, not against a production merchant account with a real card. That last mile, real keys, a real webhook tunnel, a real subscription, is a separate step I have not taken yet, and I am not going to call the engine battle-tested until I have.&lt;/p&gt;

&lt;p&gt;One more, on what is open. The core seam is open source and you can read all of it: the contract, the manager, the access gate, the entitlement resolver, the currency-first plan shape. The add-on is a paid, proprietary package, so the driver bodies, the webhook verification, and the enforcement internals are described here, not dumped. The free core staying genuinely free is what pays for that line existing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread, again
&lt;/h2&gt;

&lt;p&gt;Every phase in this series lands on the same shape: the interesting engineering is fine, but the real lesson is in a default that was right for one app and wrong for a reusable one. For billing it was two defaults at once. One provider, because the app I extracted from only ever needed one. One currency, because its one user paid in one. Both were perfectly fine for a CRM with a single customer, and both would have quietly fenced the reusable core into a single market. The work was not building Stripe support. It was building billing so that Stripe is one swappable cartridge, and so the price tag is not in dollars by assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Code, versioned and Pest-tested before each tag: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The project and roadmap: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>saas</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a SaaS engine in public: suspending a tenant without locking out the bystanders</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Thu, 04 Jun 2026 12:59:01 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-suspending-a-tenant-without-locking-out-the-bystanders-42bi</link>
      <guid>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-suspending-a-tenant-without-locking-out-the-bystanders-42bi</guid>
      <description>&lt;p&gt;I tagged &lt;code&gt;v0.10.0&lt;/code&gt; of LaraFoundry this week: the admin companies console. It is the second screen of the operator panel, after admin users a few releases back, and the headline feature is that a platform operator can suspend a whole tenant now. Blocking the company turned out to be the easy half. The half worth writing about is not locking out the people who belong to other companies too.&lt;/p&gt;

&lt;p&gt;LaraFoundry is a SaaS core I'm extracting in public from a live CRM, one module at a time. An earlier version of that CRM already runs in production, so most phases are less "invent a feature" and more "lift a feature out, and notice the habit that was fine for one app and wrong for a reusable core." This phase had a good one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the donor "blocked" a company
&lt;/h2&gt;

&lt;p&gt;The honest answer is that it didn't, not directly. There was no company-level block in the admin at all. If you needed to cut off a tenant, you banned its owner, and a middleware then denied access to anyone whose owner was banned. The company went dark as a side effect of a person being punished.&lt;/p&gt;

&lt;p&gt;That works right up until it doesn't. Banning the owner and suspending the company are two different operator actions with two different meanings. An owner can be a bad actor while their company keeps paying and keeps a dozen innocent employees working. A company can be suspended for non-payment while nobody did anything wrong. Folding both into "ban the owner" means you can never express one without implying the other, and the audit trail records the wrong story either way.&lt;/p&gt;

&lt;p&gt;So the rule for this phase was to make a company block a first-class thing, separate from a user block, and to put its enforcement somewhere a future me can't accidentally route around.&lt;/p&gt;

&lt;h2&gt;
  
  
  One column, enforced at one boundary
&lt;/h2&gt;

&lt;p&gt;A blocked company is one nullable timestamp on the companies table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isBlocked&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;company_blocked_at&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That column is not in &lt;code&gt;$fillable&lt;/code&gt;, so no tenant can POST its way back to active. Setting it is an operator-only action behind a policy, and it writes an entry to the activity log so there is a record of who suspended what and why.&lt;/p&gt;

&lt;p&gt;The interesting decision is where the block is &lt;em&gt;enforced&lt;/em&gt;. The tempting answer is "add an &lt;code&gt;if ($company-&amp;gt;isBlocked())&lt;/code&gt; check to the controllers that matter." That is exactly the donor's scattered-middleware pattern in a new coat, and it rots the same way: the day someone adds a new tenant-scoped route and forgets the check, the block has a hole.&lt;/p&gt;

&lt;p&gt;LaraFoundry already has a single place every tenant-scoped request must pass: the middleware that resolves and requires an active company. That is the one chokepoint where "this tenant is suspended" has to be true or false for the whole request. So the block lives there. One column, read at one boundary, takes the entire tenant down. There is nothing to forget on the next route.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually took the thought
&lt;/h2&gt;

&lt;p&gt;Here is the trap. A user is not a member of one company. They can own one, be an employee of two others, and have an active company selected out of those. If an operator suspends the company that happens to be active for that user, a naive enforcement check does the worst possible thing: it blocks the request, the user gets bounced, and because their active company is still the suspended one, every subsequent request bounces too. You have effectively logged out and locked out a person who did nothing wrong and still has perfectly good companies to work in.&lt;/p&gt;

&lt;p&gt;So the enforcement is not a wall, it is a self-heal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleBlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// promote the next company that ISN'T blocked, then replay the request&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setNextAvailableCompany&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fullUrl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// no other company to fall back to: drop the active one and show the&lt;/span&gt;
    &lt;span class="c1"&gt;// blocked screen. no logout, no redirect loop.&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clearActiveCompany&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'larafoundry.tenancy.blocked'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user has another company that isn't blocked, the middleware quietly switches them into it and replays the original request, so they land where they were headed in a tenant that still works. Only if there is genuinely nowhere to go does it clear the active company and show a "this company is suspended" screen. It never logs them out, because logging out a multi-company user over one suspended company is wrong, and it never loops, because the fallback state is stable.&lt;/p&gt;

&lt;p&gt;Making "the next available company" actually skip blocked ones is a one-line addition that is easy to miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;companies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'company_blocked_at'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// don't promote a user INTO a blocked company&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the company switcher gets the same guard, so a user can't manually select a suspended company from the dropdown either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review earned its keep, again
&lt;/h2&gt;

&lt;p&gt;Every phase in this series has a moment where the code review catches something I was about to ship, and this one had two, both in exactly this area.&lt;/p&gt;

&lt;p&gt;The first cut of the enforcement had a redirect loop: it bounced a blocked-company request to a safe route, but that route ran through the same middleware, saw the same still-blocked active company, and bounced again. The fix is the self-heal above, which changes the active company &lt;em&gt;before&lt;/em&gt; redirecting, so the next pass sees a different, valid tenant.&lt;/p&gt;

&lt;p&gt;The second was the promotion query. My first &lt;code&gt;setNextAvailableCompany&lt;/code&gt; happily promoted whatever company came first, including a blocked one, which meant an operator could suspend a tenant and the affected user would get auto-switched right back into it. The &lt;code&gt;whereNull('company_blocked_at')&lt;/code&gt; clause is small and it is the whole point.&lt;/p&gt;

&lt;p&gt;There was also a duller but real bug the review flagged on the way through: the company list filters took array query parameters like &lt;code&gt;?status[]=x&lt;/code&gt; and blew up with a 500 because the base filter assumed scalar values. That one wasn't even about blocking, but the fix (skip any non-scalar filter value instead of trying to bind it) hardened the admin users filter at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read-only line, on purpose
&lt;/h2&gt;

&lt;p&gt;The console also shows each company's subscription status, classified into a small fixed vocabulary: on trial, active, expiring soon, expired, or never activated. An operator can see, at a glance, which tenants are about to lapse.&lt;/p&gt;

&lt;p&gt;What the console deliberately cannot do is &lt;em&gt;manage&lt;/em&gt; any of that. There is no "extend this subscription," no "change the plan," no "grant a manual trial" button. The status is read-only, computed from the billing columns the free core already carries, and the UI says as much in plain text: subscription management lives in the billing add-on.&lt;/p&gt;

&lt;p&gt;This is the same free-versus-paid line I drew when I shipped the billing seam two phases ago. The free core is a complete multi-tenant operator console: list your tenants, inspect them, suspend the ones you need to. The moment the job becomes &lt;em&gt;moving money and changing what a customer is entitled to&lt;/em&gt;, that is the paid &lt;code&gt;larafoundry-billing&lt;/code&gt; add-on, a separate package. Showing the status is reading state the core already owns. Changing it is the product I'm going to charge for. Drawing that line through a single screen, so the free half is genuinely useful and the paid half is genuinely separate, is most of the design work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honesty section
&lt;/h2&gt;

&lt;p&gt;A few things this phase is not. The company detail view is read-only: you can inspect a tenant, you cannot edit its details from the operator panel yet. The block stores a reason on the backend and audits it, but the reason-capture UI is minimal, not a polished workflow. And the admin dashboard with platform-wide metrics is deliberately deferred, because half its widgets want modules that don't exist in the core yet and the revenue half wants the billing add-on. Better an honest second console screen than a dashboard padded with two real numbers and a lot of "coming soon."&lt;/p&gt;

&lt;p&gt;What shipped is the screen, green: the package is at 398 Pest tests and 81 Vue tests at the &lt;code&gt;v0.10.0&lt;/code&gt; tag, the security review came back clean, and six review findings (the two above among them) are fixed with regression tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread, again
&lt;/h2&gt;

&lt;p&gt;The recurring lesson of this series is that the bug is almost never in the feature, it is in the default. Here the feature was easy: suspend a tenant. The default that needed unlearning was the donor's, where blocking a company meant banning a person, and the trap that needed avoiding was the obvious enforcement check that would have locked out everyone standing nearby. A block that takes down the right tenant and quietly steps the bystanders aside is a much smaller diff than it sounds, and it is the entire difference between an operator tool you can trust and one that generates support tickets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Code, versioned and Pest-tested before each tag: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The project and roadmap: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>saas</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a SaaS engine in public: shipping the billing seam, not billing</title>
      <dc:creator>Dmitry Isaenko</dc:creator>
      <pubDate>Thu, 04 Jun 2026 09:58:28 +0000</pubDate>
      <link>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-shipping-the-billing-seam-not-billing-lpe</link>
      <guid>https://dev.to/d_isaenko_dev/building-a-saas-engine-in-public-shipping-the-billing-seam-not-billing-lpe</guid>
      <description>&lt;p&gt;I tagged &lt;code&gt;v0.9.0&lt;/code&gt; of LaraFoundry this week: billing. Except the honest headline is that I shipped the billing &lt;em&gt;seam&lt;/em&gt;, not billing. The free core now has the whole shape of a subscription system, a payment-gateway contract, a driver manager, a real access gate over subscription columns, and it cannot take a single cent. That is on purpose, and the reason is the most interesting part of the phase.&lt;/p&gt;

&lt;p&gt;LaraFoundry is a SaaS core I'm extracting in public from a live CRM, one module at a time. The deal I made with myself early on: the core is free and stays free for everything except money. Auth, multi-tenancy, RBAC, the admin console, the activity log, i18n, files: all free. The day a business wants to &lt;em&gt;charge its customers&lt;/em&gt;, that is the paid part. So billing could not just be "another module." It had to split cleanly down a line, with the free side carrying real, useful structure and the paid side carrying the parts that actually move money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The donor habit I would not carry
&lt;/h2&gt;

&lt;p&gt;Here is what the original CRM did when a company "paid" for its subscription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TODO: real payment gateway integration&lt;/span&gt;
&lt;span class="c1"&gt;// TEMPORARY: every payment is successful, for testing&lt;/span&gt;
&lt;span class="nv"&gt;$paymentStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;success&lt;/code&gt; was hardcoded. There was no Stripe, no Paddle, no gateway at all. "Paying" wrote a row into a &lt;code&gt;company_payments&lt;/code&gt; table and flipped the subscription date forward. For a CRM I run myself, with one real user, that was fine: I never needed the real thing, so the placeholder sat there indefinitely.&lt;/p&gt;

&lt;p&gt;The moment this becomes a reusable core, that placeholder is poison. A &lt;code&gt;success&lt;/code&gt; that is always true is worse than no gateway, because it &lt;em&gt;looks&lt;/em&gt; like billing works. So the rule for this phase was simple: the fake gateway does not get extracted. Whatever stands in its place has to be honest about the fact that it takes no money.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the seam actually is
&lt;/h2&gt;

&lt;p&gt;The free core ships a &lt;code&gt;PaymentGatewayInterface&lt;/code&gt;: subscribe, cancel, refund, status, and verify a webhook. It describes only the &lt;em&gt;mechanics&lt;/em&gt; of moving money. It deliberately says nothing about tax or invoicing, because that responsibility differs by gateway type (with a PSP like Stripe the client is the merchant of record and owns the VAT; with a merchant-of-record like Paddle the provider owns it). Folding tax into a gateway method would bake one wrong model into both.&lt;/p&gt;

&lt;p&gt;The core registers exactly one driver against that contract: the null gateway. It refuses every money operation, loudly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Tenant&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$planId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;notConnected&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No silent &lt;code&gt;success&lt;/code&gt;. If something calls it without a real driver bound, it fails fast and tells you to install the add-on. The driver is resolved by a manager in the same style as Laravel's Mail or Queue manager: a config key picks the driver, the add-on registers real ones (&lt;code&gt;stripe&lt;/code&gt;, &lt;code&gt;paddle&lt;/code&gt;) via &lt;code&gt;extend()&lt;/code&gt;, a host in a country those don't reach can register its own local PSP. Swapping providers is one config value, and no call site knows which gateway it got. That gateway-agnostic shape is the part I think matters most for non-US builders: most Laravel SaaS starters are Stripe-only, and Stripe reaches roughly 46 countries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that does the real work
&lt;/h2&gt;

&lt;p&gt;The one piece of "billing logic" that genuinely belongs in the free core is the access decision. Before this phase, &lt;code&gt;Company::hasAccess()&lt;/code&gt; was a stub that returned &lt;code&gt;true&lt;/code&gt;, a seam planted back in the RBAC phase so call sites would not change when billing landed. Now it reads real state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hasAccess&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nc"&gt;LaraFoundryBilling&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subscriptionState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasAccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With billing disabled (the default), it is always &lt;code&gt;true&lt;/code&gt;. The free core is a complete multi-tenant app with no paywall, and that is the whole promise. Turn billing on and it reads the subscription columns: a live trial or an active subscription grants access, anything else denies. Fail-closed when enabled, fully open when not.&lt;/p&gt;

&lt;p&gt;The subscription columns (&lt;code&gt;trial_ends_at&lt;/code&gt;, &lt;code&gt;subscription_ends_at&lt;/code&gt;, &lt;code&gt;plan_id&lt;/code&gt;, ...) ship in the free core's migration, not the add-on's. That is a deliberate call: the gate has to read real state with no add-on installed, and a free self-host should be able to grant a trial by hand. The add-on only &lt;em&gt;writes&lt;/em&gt; those columns (its webhook keeps &lt;code&gt;subscription_ends_at&lt;/code&gt; current). It adds no column of its own to the core table. And those columns are not mass-assignable, so a tenant can never POST its way to a free year of subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honesty section, because every release has one
&lt;/h2&gt;

&lt;p&gt;The thing I am most careful not to overclaim: this phase wires the gate but no caller consults it yet. The intended loop is that the RBAC policy checker, or a "subscription required" middleware, asks &lt;code&gt;hasAccess()&lt;/code&gt; before letting a request through. That wiring is not in this phase. So today, enabling billing makes the gate &lt;em&gt;answer&lt;/em&gt; correctly, but nothing in the core &lt;em&gt;enforces&lt;/em&gt; it. Returning real state now means those future call sites won't have to change when they arrive, which was the entire point of planting the stub two phases ago. But "the paywall enforces itself" is not a claim I get to make yet, and I'm not making it.&lt;/p&gt;

&lt;p&gt;And the obvious one: there are no real payments here. No Stripe, no Paddle, no Cashier, not even as a dependency. No plans, no promo codes, no trial UI, no billing portal, no revenue metrics. All of that is the paid &lt;code&gt;larafoundry-billing&lt;/code&gt; add-on, a separate package, a later milestone. What shipped is the contract those things stand on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread, again
&lt;/h2&gt;

&lt;p&gt;Every phase in this series lands on the same shape. The interesting engineering is fine; the lesson is in a default or a habit that was right for one app and wrong for a reusable one. This time it was a hardcoded &lt;code&gt;success&lt;/code&gt;: a placeholder that worked perfectly for a CRM with one user and would have been a quiet disaster in a core other people deploy. The fix was not to build a real gateway. It was to build the honest absence of one: a seam that takes no money and says so.&lt;/p&gt;

&lt;p&gt;The billing engine is the add-on. Drawing the free/paid line so the core stays genuinely free was the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow along
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Code, versioned and Pest-tested before each tag: &lt;a href="https://github.com/dmitryisaenko/larafoundry" rel="noopener noreferrer"&gt;github.com/dmitryisaenko/larafoundry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The project and roadmap: &lt;a href="https://larafoundry.com" rel="noopener noreferrer"&gt;larafoundry.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>saas</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
