DEV Community

Cover image for I Kept Rebuilding the Same SaaS Foundation, So I Packaged It Into a Starter Kit
tampe01
tampe01

Posted on

I Kept Rebuilding the Same SaaS Foundation, So I Packaged It Into a Starter Kit

I Kept Rebuilding the Same SaaS Foundation, So I Packaged It Into a Starter Kit

Every side project I start goes through the same first three days: set up auth,
wire up roles and permissions, hook up Stripe subscriptions. None of it is hard.
All of it is tedious. And it's the same code, slightly rewritten, every single time.

So this time I built it once, properly, and packaged it as a starter kit:
Laravel 12 + React/TypeScript, with authentication, role-based permissions,
and Stripe subscriptions already wired together.

Here's what went into it, and a few of the trade-offs I made along the way.

The stack

Backend

  • Laravel 12
  • Laravel Sanctum for API token authentication
  • Spatie's laravel-permission for roles
  • Laravel Cashier for Stripe subscriptions

Frontend

  • React 18 + TypeScript
  • Tailwind CSS
  • Vite

I kept the frontend and backend as two separate applications talking over a
REST API, rather than a monolithic Blade+Inertia setup. It's slightly more
wiring up front (CORS, token handling), but it means either side can be
swapped out independently — useful if you'd rather ship a Vue frontend or a
mobile app against the same API later.

Auth: Sanctum over Passport

For a starter kit meant to be cloned into many different projects, Sanctum's
simplicity won over Passport's OAuth2 machinery. Most SaaS products don't need
third-party OAuth clients — they need a token that identifies "this user, on
this device." Sanctum does exactly that with almost no ceremony:

public function login(Request $request)
{
    $validated = $request->validate([
        'email' => ['required', 'string', 'email'],
        'password' => ['required', 'string'],
    ]);

    if (! Auth::attempt($validated)) {
        throw ValidationException::withMessages([
            'email' => ['Les identifiants fournis sont incorrects.'],
        ]);
    }

    $user = User::where('email', $validated['email'])->firstOrFail();
    $token = $user->createToken('api-token')->plainTextToken;

    return response()->json(['user' => $user, 'token' => $token]);
}
Enter fullscreen mode Exit fullscreen mode

Roles: keep it boring

I used Spatie's laravel-permission package rather than rolling custom roles.
It's the de facto standard for a reason — it's boring, well-tested, and
integrates with route middleware with almost no code:

class EnsureIsAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user() || ! $request->user()->hasRole('admin')) {
            return response()->json(['message' => 'Accès refusé.'], 403);
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

For a starter kit, "boring and standard" beats "clever and custom" every time —
whoever clones this needs to be productive in it immediately, not learn a new
permission system.

Stripe: Cashier, and a decision I went back and forth on

Laravel Cashier handles the subscription lifecycle (create, cancel, resume,
invoices) with a fraction of the code you'd write against the raw Stripe SDK.
The one decision I debated: single-tenant vs multi-tenant billing.

I went single-tenant for v1 — one Stripe customer per user account, no teams
or organizations. It's the right default for a starter kit: most SaaS ideas
don't need multi-tenancy on day one, and adding a team_id column later is a
much smaller lift than ripping out an over-engineered tenancy layer you didn't
need.

public function subscribe(Request $request)
{
    $validated = $request->validate([
        'payment_method' => ['required', 'string'],
        'price_id' => ['required', 'string'],
    ]);

    $user = $request->user();
    $user->createOrGetStripeCustomer();
    $user->updateDefaultPaymentMethod($validated['payment_method']);

    $subscription = $user->newSubscription('default', $validated['price_id'])
        ->create($validated['payment_method']);

    return response()->json(['subscription' => $subscription]);
}
Enter fullscreen mode Exit fullscreen mode

What's in the frontend

Five screens, all consuming the API above: login, register, dashboard,
billing (plans + invoice history), and profile. Auth state lives in a React
context that checks for a stored token on load and fetches the current user —
nothing exotic, just enough to not have to think about it once it's cloned
into a new project.

Where it's at now

It's packaged with full setup docs (backend, frontend, CORS, Stripe config,
deployment) and live on Gumroad: https://tampewilliams.gumroad.com/l/saas-starter-laravel-react

I'd genuinely like to know: if you were starting a SaaS side project tomorrow,
what would you consider the minimum viable foundation? Auth + roles +
billing feels right to me, but I'm curious what others would add or cut.

Top comments (0)