Back to blog
laravel saas mentorship work-experience rebrand iteration

21 commits in a week: building a SaaS with my daughter as the product owner

The Itch

This project exists because my daughter needed a work-experience placement and the school's default was "a couple of weeks shadowing someone in an office". I run a software practice. The shadowing version would have been deathly boring for both of us, so I pitched the school a different shape: she'd be the product owner of a real SaaS, I'd be her engineer, and we'd ship something. They said yes.

The premise mattered. She wasn't there to learn to code — she was there to learn what it actually takes to build a product. That meant she set the direction. The brief came from her, not from me. My job was to translate her brief into something a Laravel app could do, then keep handing the screen back so she could react to it.

I had bones I could reuse. An earlier project — a personal life-admin tool — already solved auth, multi-tenant data isolation, the reminder pipeline, the share-by-link mechanic, the plan-gating middleware. Boring on the surface, but the scaffolding was right; the domain was wrong. The honest decision was: don't build her brief from scratch. Build it on top of the bones.

How It Started

May 18, 12:08pm — first day of her placement. Empty Laravel 13 scaffold. Initial commit is the default laravel/laravel skeleton — composer.json, the stock README that's just a Laravel marketing page, default User model. No features, no opinions.

Ninety minutes later, at 1:47pm: commit 5dac24f, message "Build [the platform] on [the earlier project's] foundation". The diff is the rest of the application. It replicates the household / register / entry / alert / Stripe stack wholesale, with two surgical changes:

  • The category catalogue (RegisterType) shifts from per-household to a global super-admin-managed catalogue. Households don't enable or disable categories any more — they see whatever the super admin has published.
  • The leaf entity changes shape: FamilyMember becomes a new domain object with a different field set (species, breed, sex, colour, DOB, microchip, photo). Every RegisterEntry now belongs to one of those instead of a family member.

Plan tier limits got rescoped — out went "max active registers per household", in came gates on the new entity count, storage, OCR, sharing, API access. The seed data shipped eight default record categories specific to the new domain.

And one bug got fixed in the pivot: the earlier project's AlertService had a latent collision where custom-field expiry alerts could be overwritten by the standard expiry-date alert. Namespacing the alert types so processStandardDateFields() couldn't accidentally delete custom ones — done in the same commit.

So: in ninety minutes, the empty Laravel scaffold became a working domain-specific SaaS. Not because the work was done in ninety minutes — but because most of the work had been done in another repo months earlier, and this commit moved it across with the right surgical edits.

The Path To Here

Day one: get it onto Laravel Cloud. Four commits over the rest of the first afternoon. 7ce55a6 added an nixpacks.toml to run npm run build on the host. de2f253 chained the npm build into the install phase. d4b57ea forced the frontend build via a composer post-install hook because the install hook alone wasn't reliably catching it. 00d4f83 dropped in the first logo and the PWA app-icon set. The deploy machinery was the first thing that took real fiddling — three commits to get the static asset pipeline working on a managed host, each one fixing the failure mode of the previous.

Days 2–4: the entity gets a face. 0ba312c added a profile page for the new core entity and made entry creation start from the entity rather than from the category. 6b02c09 moved S3 credentials out of .env and into a super-admin settings UI — so I could switch storage backends from the browser instead of editing config and redeploying. 2e6205c added a catch-up migration to record which disk a photo was uploaded to (because some photos were already on S3 and some on local disk). 5dd25ac fixed the resulting bug: browser-loadable URLs for S3 attachments need to be signed, not plain Storage::url() calls. None of this was exciting. It was the unsexy "make the data layer usable from a browser" work that every project hits around week one.

Days 5–6: the brand cycle that didn't stick. 76f9d8c rewrote the marketing copy around a new brand line — her brand line, after she sat with a notebook and wrote a few options out before picking one. Then four commits — a32be78, 7967443, fc894d5, ecc41f6 — labelled "Phase 1", "Phase 1b + Phase 2", "Phase A polish", "Phase B + C". Each one was a session where she'd look at the screen, name something she didn't like, and I'd change it. A cream/sage/terracotta/charcoal token system, a "Today's Care" dashboard module, a health timeline, a quick-action bar, a task checklist, an entity photo gallery. The colour palette was warm, earthy, slightly retro. It felt cohesive. It shipped. I thought we were done.

We weren't done. Day 7: the second rebrand. Commit 69b0f4b — "Rebrand to teal #4A8F9F, swap logo, restructure dashboard". She'd been carrying around a different palette and a different mark in her head all week. Once we'd lived with the warm-earth version for a couple of days she could articulate what was wrong with it: it didn't feel modern enough, and the colour didn't carry the calm-reassuring feeling she wanted. So: sage green out, teal in. New logo. New favicon. The Tailwind sage palette key got retuned in place — same name, completely different hex values — because renaming would have touched 358 class references and the value was in the colour change, not the naming. The dashboard's "Recent activity" card got swapped for "Upcoming appointments & renewals". The bottom Quick-add card became "Recent uploads". The Tailwind palette name now technically lies about its colour, but the trade-off is reversibility: one config change to roll back versus 358 line edits.

Day 7, same evening: the cache trap and the pricing reset. 2f0cc90 pushed the teal further — the marketing nav and app sidebar had been using the dark sage-700 instead of the brand mid-tone sage-500, so big surfaces were rendering as #2D5E69 (dark teal) instead of #4A8F9F (brand teal). She spotted that one — I'd shipped the rebrand thinking we were done; she opened it on her phone and said "but it's the wrong colour". Same commit added ?v=2 to every logo and favicon URL because Laravel Cloud clears server-side caches on deploy but browsers happily kept serving stale /favicon.ico and /images/logo.png from disk. Then 26f6f20 added an auto-redirect from / to /app for logged-in users — small thing, but it cut a redundant marketing-landing-then-click flow. Finally db78eb6 reset the pricing page: out went the three-tier monthly model (Free / Pro £4.99 / Pro+ £9.99), in came a 14-day no-card trial → Core (£14.99/year or £24.99 lifetime) → "Pro coming soon" structure. That came from her too. She'd thought about who'd actually pay, looked at the £9.99/month tier we'd shipped, and said something close to "nobody I know would pay for this monthly". So we rebuilt it.

The pattern. Three things shipped twice in seven days: the brand identity (warm-earth → teal), the pricing model (3-tier monthly → trial + lifetime), and the dashboard structure (two distinct layouts within a week). That's not chaos — that's a project getting iterated on while it has zero users and the cost of changing your mind is just git commit. The instinct to "wait until I'm sure" is the most expensive instinct you can have in week one of a SaaS. The whole point of week one is figuring out what you're sure about. Having a product owner whose job description was literally "look at this and tell me what you think" is the fastest way to figure that out.

What It Looks Like Now

Architecture: Laravel 12 + Blade + Alpine + Tailwind v3 + Vite v7. Multi-tenant via a Household → Membership → User triangle. Records belong to entities, entities belong to households, households own attachments via S3-compatible storage with signed URLs. Reminders run through a single AlertService that walks active records and emits/updates per-entry alerts. Plan tiers are gated through middleware on entity count, storage, and feature flags (OCR, sharing, API). Super admin owns the global record-category catalogue and can edit field schemas from a UI.

What it does today: Lets a household keep records of their entities — categorised, with attachments, with reminders before things expire or come due. Shares snapshots over signed links to outside parties without requiring an account. 14-day free trial, then Core at £14.99/year or £24.99 lifetime. Pro tier with OCR, more storage, and unlimited entities is on the marketing roadmap but not yet built.

What I'd Tell Past-Me

  • A non-developer product owner is a feature, not a constraint. Every iteration in this repo — the two brands, the two pricing models, the dashboard restructure, even the spotting of "the wrong shade of teal is dominating the marketing nav" — came from her looking at the screen and saying something specific. Left to myself I would have shipped v1 of each thing and called it done. Having someone whose explicit job was to keep saying "but how does this feel" is what produced the second version. The second version is the right one. Every time.
  • Work experience can produce real software. I went into this assuming we'd build a toy and that the value was the learning. That was the wrong frame. The value was real: a real product, with real opinions, that she could put on a CV. The "learning" happened naturally because every decision was a real decision with real consequences. There's no replacement for that.
  • Cloning your own codebase is faster than starting fresh. Ninety minutes to go from empty scaffold to working domain-specific SaaS is only possible because the auth, the reminders, the share mechanic, the plan gating, the Stripe wiring — all of that was already done somewhere else. The cost is that you carry along latent bugs (the alert-type collision was already in the source repo). Worth it. Easily.
  • Build deployment first, features second. Four commits on day one were about getting the asset pipeline to behave on Laravel Cloud. I was tempted to skip ahead and do "interesting" work. Resisting that and getting the deploy boring first paid back every subsequent push.
  • Two rebrands in a week sounds like waste. It isn't. The first brand wasn't wrong — it just wasn't the right one. The second brand wouldn't have existed without the first one as a reference point. Building → looking → rebuilding is faster than designing → guessing → committing.
  • The Tailwind palette retune-in-place is a cheap trick that paid off. Keeping the key called sage while filling it with teal values felt ugly at the time. It saved 358 line edits and made the rebrand a single-commit revert if it had bombed. I'd do it again.
  • Server-side caches clearing on deploy ≠ all caches cleared. Browsers cache by URL. If your /favicon.ico and /images/logo.png URLs don't change between deploys, your users will see yesterday's brand for days. The ?v=N trick is the dumbest fix that works and there's no shame in it.
  • Carrying a known bug forward in a pivot deserves a commit message line. The "AlertService had a pre-existing bug — fixed" note in the pivot commit is exactly the kind of thing future-me would otherwise spend an hour digging for. Document what you fixed and what you imported as still-broken.
  • Pricing v1 doesn't matter as much as I keep telling myself it does. I spent real effort designing Free/Pro/Pro+ in the original platform. It got replaced in seven days. The version you build before you have a single paying user is a placeholder. Treat it as one.

Tech Notes

Current stack

  • Laravel 12 (PHP 8.2+), Blade + Alpine.js + Tailwind v3
  • Vite 7 for bundling
  • MySQL, database-backed sessions/cache/queue
  • AWS S3 (via flysystem) for attachments, signed URLs for browser-loadable photo URLs
  • Stripe via Laravel Cashier for billing
  • OpenAI / Anthropic SDKs for OCR document extraction (Pro-tier feature, not yet wired)
  • Laravel Cloud for hosting, Cloudflare R2 as the S3-compatible storage backend
  • ImageMagick (host-installed) for ad-hoc image processing during brand work

Notable dependencies that came in during the week

The composer manifest barely moved after the foundation commit — most of the action was in views and controllers, not the dependency graph. Laravel Cashier and Breeze came in with the initial pivot. No additions in any later commit. Frontend was the same shape throughout (Tailwind + Alpine, no React/Vue/Livewire).

Useful commands worth remembering

# Promote yourself to super admin
php artisan app:create-super-admin [email protected]

# Run app + queue + log tail + vite all together for local dev
composer run dev

# Smoke-test a Laravel controller render in tinker without an HTTP request
php artisan tinker --execute="
  \$user = App\Models\User::find(1);
  auth()->login(\$user);
  \$resp = app()->call([app(App\Http\Controllers\AppController::class), 'dashboard']);
  echo strlen(\$resp->render()).' chars';
"

The tinker render trick saved a lot of "boot a server, log in, navigate" cycles during the rebrand. If you change a controller or a view that's wrapped in auth + middleware, that one-liner tells you in a second whether it still renders before you ever touch a browser.

Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.